mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-15 20:08:18 +08:00
feat(agent,mcp): validate tool bindings on save and keep returnDirect raw-name config working
This commit is contained in:
parent
2048768baf
commit
5cb82ed8f8
@ -12,8 +12,11 @@ import vip.mate.agent.binding.model.AgentToolBinding;
|
|||||||
import vip.mate.agent.binding.repository.AgentProviderPreferenceMapper;
|
import vip.mate.agent.binding.repository.AgentProviderPreferenceMapper;
|
||||||
import vip.mate.agent.binding.repository.AgentSkillBindingMapper;
|
import vip.mate.agent.binding.repository.AgentSkillBindingMapper;
|
||||||
import vip.mate.agent.binding.repository.AgentToolBindingMapper;
|
import vip.mate.agent.binding.repository.AgentToolBindingMapper;
|
||||||
|
import vip.mate.exception.MateClawException;
|
||||||
import vip.mate.skill.runtime.SkillRuntimeService;
|
import vip.mate.skill.runtime.SkillRuntimeService;
|
||||||
import vip.mate.skill.runtime.model.ResolvedSkill;
|
import vip.mate.skill.runtime.model.ResolvedSkill;
|
||||||
|
import vip.mate.tool.model.AvailableToolDTO;
|
||||||
|
import vip.mate.tool.service.AvailableToolService;
|
||||||
|
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
import java.util.LinkedHashSet;
|
import java.util.LinkedHashSet;
|
||||||
@ -43,16 +46,25 @@ public class AgentBindingService {
|
|||||||
* graph when SkillRuntimeService initializes after binding.
|
* graph when SkillRuntimeService initializes after binding.
|
||||||
*/
|
*/
|
||||||
private final SkillRuntimeService skillRuntimeService;
|
private final SkillRuntimeService skillRuntimeService;
|
||||||
|
/**
|
||||||
|
* Source of truth for what the picker can offer (built-in + MCP). Used
|
||||||
|
* by {@link #setToolBindings} to refuse new tool names that the runtime
|
||||||
|
* couldn't resolve anyway — closes the gap where a UI-disabled row
|
||||||
|
* could still be saved by hitting the API directly.
|
||||||
|
*/
|
||||||
|
private final AvailableToolService availableToolService;
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
public AgentBindingService(AgentSkillBindingMapper skillBindingMapper,
|
public AgentBindingService(AgentSkillBindingMapper skillBindingMapper,
|
||||||
AgentToolBindingMapper toolBindingMapper,
|
AgentToolBindingMapper toolBindingMapper,
|
||||||
AgentProviderPreferenceMapper providerPreferenceMapper,
|
AgentProviderPreferenceMapper providerPreferenceMapper,
|
||||||
@Lazy SkillRuntimeService skillRuntimeService) {
|
@Lazy SkillRuntimeService skillRuntimeService,
|
||||||
|
AvailableToolService availableToolService) {
|
||||||
this.skillBindingMapper = skillBindingMapper;
|
this.skillBindingMapper = skillBindingMapper;
|
||||||
this.toolBindingMapper = toolBindingMapper;
|
this.toolBindingMapper = toolBindingMapper;
|
||||||
this.providerPreferenceMapper = providerPreferenceMapper;
|
this.providerPreferenceMapper = providerPreferenceMapper;
|
||||||
this.skillRuntimeService = skillRuntimeService;
|
this.skillRuntimeService = skillRuntimeService;
|
||||||
|
this.availableToolService = availableToolService;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== Skill Bindings ====================
|
// ==================== Skill Bindings ====================
|
||||||
@ -335,9 +347,27 @@ public class AgentBindingService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 批量设置 Agent 的 tool 绑定(替换模式)
|
* Replace the agent's tool binding set.
|
||||||
|
*
|
||||||
|
* <p>Validation rule for each incoming name:
|
||||||
|
* <ul>
|
||||||
|
* <li><b>Already in the existing binding</b> → always allowed (so the
|
||||||
|
* user can keep a previously-bound tool whose upstream MCP server
|
||||||
|
* is currently stale or even removed; the client just keeps what
|
||||||
|
* it already had).</li>
|
||||||
|
* <li><b>New addition (not in existing binding)</b> → must appear in
|
||||||
|
* {@link AvailableToolService#listAvailable()} with
|
||||||
|
* {@code available == true}. Names that are unknown
|
||||||
|
* (typos / legacy unprefixed MCP names / hand-crafted strings) or
|
||||||
|
* that the picker marked unavailable (hash collision, etc.) are
|
||||||
|
* rejected — saving them would put a {@code mate_agent_tool} row
|
||||||
|
* in the database that the runtime can never resolve, which then
|
||||||
|
* silently drops the tool when the agent runs.</li>
|
||||||
|
* </ul>
|
||||||
*/
|
*/
|
||||||
public void setToolBindings(Long agentId, List<String> toolNames) {
|
public void setToolBindings(Long agentId, List<String> toolNames) {
|
||||||
|
validateNewToolBindings(agentId, toolNames);
|
||||||
|
|
||||||
toolBindingMapper.delete(
|
toolBindingMapper.delete(
|
||||||
new LambdaQueryWrapper<AgentToolBinding>()
|
new LambdaQueryWrapper<AgentToolBinding>()
|
||||||
.eq(AgentToolBinding::getAgentId, agentId));
|
.eq(AgentToolBinding::getAgentId, agentId));
|
||||||
@ -352,6 +382,56 @@ public class AgentBindingService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refuse the save when any *newly-added* tool name doesn't resolve to
|
||||||
|
* an {@code available=true} row in the picker. Names already in the
|
||||||
|
* existing binding are exempt so that subsequent edits (especially
|
||||||
|
* "remove this stale tool") still succeed even if upstream state has
|
||||||
|
* drifted.
|
||||||
|
*/
|
||||||
|
private void validateNewToolBindings(Long agentId, List<String> incoming) {
|
||||||
|
if (incoming == null || incoming.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Set<String> existing = listToolBindings(agentId).stream()
|
||||||
|
.map(AgentToolBinding::getToolName)
|
||||||
|
.collect(Collectors.toSet());
|
||||||
|
Set<String> bindable;
|
||||||
|
try {
|
||||||
|
bindable = availableToolService.listAvailable().stream()
|
||||||
|
.filter(AvailableToolDTO::isAvailable)
|
||||||
|
.map(AvailableToolDTO::getName)
|
||||||
|
.collect(Collectors.toSet());
|
||||||
|
} catch (Exception e) {
|
||||||
|
// The picker source briefly failing must not block the user
|
||||||
|
// from saving a binding that's still in their existing set.
|
||||||
|
// Re-validate everything against just the existing set —
|
||||||
|
// strictly conservative: only allow keeps, refuse adds.
|
||||||
|
log.warn("AvailableToolService unavailable during binding validation, falling back to existing-only: {}",
|
||||||
|
e.getMessage());
|
||||||
|
bindable = Set.of();
|
||||||
|
}
|
||||||
|
|
||||||
|
List<String> rejected = new java.util.ArrayList<>();
|
||||||
|
for (String name : incoming) {
|
||||||
|
if (name == null || name.isBlank()) {
|
||||||
|
rejected.add("<blank>");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (existing.contains(name)) continue; // keeps are always allowed
|
||||||
|
if (!bindable.contains(name)) rejected.add(name);
|
||||||
|
}
|
||||||
|
if (!rejected.isEmpty()) {
|
||||||
|
String preview = rejected.size() <= 5
|
||||||
|
? String.join(", ", rejected)
|
||||||
|
: String.join(", ", rejected.subList(0, 5)) + " (+" + (rejected.size() - 5) + " more)";
|
||||||
|
throw new MateClawException("err.agent.tool_binding_unbindable",
|
||||||
|
"Tool name(s) cannot be bound: " + preview
|
||||||
|
+ ". Either the name is unknown or the picker marked it unavailable "
|
||||||
|
+ "(e.g. hash collision, upstream server removed).");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== Provider Preferences (RFC-009 PR-3) ====================
|
// ==================== Provider Preferences (RFC-009 PR-3) ====================
|
||||||
|
|
||||||
/** Raw rows for the agent edit form. Sorted by sort_order ascending. */
|
/** Raw rows for the agent edit form. Sorted by sort_order ascending. */
|
||||||
|
|||||||
@ -8,7 +8,7 @@ import java.util.LinkedHashSet;
|
|||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* RFC-052 §3.4 / PR-4: MCP tool return-direct opt-in list.
|
* MCP tool return-direct opt-in list.
|
||||||
*
|
*
|
||||||
* <p>Tools listed here are wrapped in {@link ReturnDirectMcpToolCallback} so
|
* <p>Tools listed here are wrapped in {@link ReturnDirectMcpToolCallback} so
|
||||||
* their results bypass the LLM context (see {@code ToolExecutionExecutor} and
|
* their results bypass the LLM context (see {@code ToolExecutionExecutor} and
|
||||||
@ -20,14 +20,27 @@ import java.util.Set;
|
|||||||
* mcp:
|
* mcp:
|
||||||
* return-direct:
|
* return-direct:
|
||||||
* tools:
|
* tools:
|
||||||
* - query_employee_salary
|
* - query_employee_salary # raw upstream name (legacy form, still supported)
|
||||||
* - read_medical_record
|
* - mcp_42_query_employee_salary_aB3xYz # full prefixed callback name (server-scoped, precise)
|
||||||
* </pre>
|
* </pre>
|
||||||
*
|
*
|
||||||
* <p>Match is by tool name only (matching the upstream {@code ToolDefinition.name()}).
|
* <p><b>Two accepted name forms</b>:
|
||||||
* Per-server scoping is intentionally out of scope for the first iteration; if
|
* <ul>
|
||||||
* the same tool name comes from two servers and only one should be direct, give
|
* <li><b>Raw upstream name</b> ({@code query_employee_salary}) —
|
||||||
* one of them a name prefix at the MCP server config layer.
|
* matches the wrapped callback's underlying delegate name. This is
|
||||||
|
* the form that existed before the runtime started prefixing
|
||||||
|
* callback names; existing deployments keep working unchanged.
|
||||||
|
* A raw name matches every server that exposes that tool, so use
|
||||||
|
* this form when a sensitive name should be direct on every
|
||||||
|
* server it appears.</li>
|
||||||
|
* <li><b>Prefixed callback name</b>
|
||||||
|
* ({@code mcp_<serverId>_<slug>_<hash6>}) — server-scoped, precise.
|
||||||
|
* Use this form when only one of several MCP servers exposing the
|
||||||
|
* same raw name should be treated as direct.</li>
|
||||||
|
* </ul>
|
||||||
|
* Matching happens via {@link #matches(String, String)} from the consumer
|
||||||
|
* side; see {@link McpToolCallbackProvider#getToolCallbacks} for the call
|
||||||
|
* site.
|
||||||
*
|
*
|
||||||
* @author MateClaw Team
|
* @author MateClaw Team
|
||||||
*/
|
*/
|
||||||
@ -35,7 +48,7 @@ import java.util.Set;
|
|||||||
@ConfigurationProperties(prefix = "mateclaw.mcp.return-direct")
|
@ConfigurationProperties(prefix = "mateclaw.mcp.return-direct")
|
||||||
public class McpReturnDirectProperties {
|
public class McpReturnDirectProperties {
|
||||||
|
|
||||||
/** Tool names that should be treated as returnDirect. */
|
/** Tool names (raw or prefixed) that should be treated as returnDirect. */
|
||||||
private Set<String> tools = Collections.emptySet();
|
private Set<String> tools = Collections.emptySet();
|
||||||
|
|
||||||
public Set<String> getTools() {
|
public Set<String> getTools() {
|
||||||
@ -46,7 +59,27 @@ public class McpReturnDirectProperties {
|
|||||||
this.tools = tools != null ? new LinkedHashSet<>(tools) : Collections.emptySet();
|
this.tools = tools != null ? new LinkedHashSet<>(tools) : Collections.emptySet();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single-string check kept for back-compat with any caller that has
|
||||||
|
* only one form of the name. Prefer {@link #matches(String, String)}
|
||||||
|
* from the wrapping path so both prefixed and raw forms get a chance
|
||||||
|
* to match.
|
||||||
|
*/
|
||||||
public boolean isReturnDirect(String toolName) {
|
public boolean isReturnDirect(String toolName) {
|
||||||
return toolName != null && tools.contains(toolName);
|
return toolName != null && tools.contains(toolName);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return {@code true} iff the configured set contains either the
|
||||||
|
* prefixed callback name OR the raw upstream tool name. Either
|
||||||
|
* argument may be {@code null} (e.g. when a callback isn't a
|
||||||
|
* {@link PrefixedNameToolCallback} so no raw form is
|
||||||
|
* available); the other is checked on its own.
|
||||||
|
*/
|
||||||
|
public boolean matches(String prefixedName, String rawName) {
|
||||||
|
if (tools.isEmpty()) return false;
|
||||||
|
if (prefixedName != null && tools.contains(prefixedName)) return true;
|
||||||
|
if (rawName != null && tools.contains(rawName)) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -40,14 +40,30 @@ public class McpToolCallbackProvider implements ToolCallbackProvider {
|
|||||||
callbacks.size(), mcpClientManager.getActiveCount());
|
callbacks.size(), mcpClientManager.getActiveCount());
|
||||||
}
|
}
|
||||||
|
|
||||||
// RFC-052: opt-in returnDirect wrapping. The decorator only changes
|
// Opt-in returnDirect wrapping. The decorator only changes
|
||||||
// ToolMetadata.returnDirect(); guard/approval/observability still
|
// ToolMetadata.returnDirect(); guard/approval/observability still
|
||||||
// see the original callback through the wrapper.
|
// see the original callback through the wrapper.
|
||||||
|
//
|
||||||
|
// Names registered by the manager are now prefixed
|
||||||
|
// (mcp_<serverId>_<slug>_<hash6>) — but operators have been
|
||||||
|
// configuring the return-direct list with raw upstream names
|
||||||
|
// (e.g. `query_employee_salary`) since long before the prefix
|
||||||
|
// existed. Match on EITHER form so an existing deployment's
|
||||||
|
// sensitive-tool isolation doesn't silently regress when this
|
||||||
|
// change rolls out: a tool counts as return-direct if its
|
||||||
|
// configured token equals (a) the prefixed callback name OR
|
||||||
|
// (b) the underlying raw tool name visible through the
|
||||||
|
// PrefixedNameToolCallback wrapper.
|
||||||
List<ToolCallback> wrapped = new ArrayList<>(callbacks.size());
|
List<ToolCallback> wrapped = new ArrayList<>(callbacks.size());
|
||||||
for (ToolCallback cb : callbacks) {
|
for (ToolCallback cb : callbacks) {
|
||||||
String name = cb.getToolDefinition() != null ? cb.getToolDefinition().name() : null;
|
String prefixed = cb.getToolDefinition() != null ? cb.getToolDefinition().name() : null;
|
||||||
if (returnDirectProperties.isReturnDirect(name)) {
|
String raw = (cb instanceof PrefixedNameToolCallback w && w.getDelegate() != null
|
||||||
log.info("[McpToolCallbackProvider] wrapping MCP tool '{}' as returnDirect (RFC-052)", name);
|
&& w.getDelegate().getToolDefinition() != null)
|
||||||
|
? w.getDelegate().getToolDefinition().name()
|
||||||
|
: null;
|
||||||
|
if (returnDirectProperties.matches(prefixed, raw)) {
|
||||||
|
log.info("[McpToolCallbackProvider] wrapping MCP tool as returnDirect (prefixed='{}', raw='{}')",
|
||||||
|
prefixed, raw);
|
||||||
wrapped.add(new ReturnDirectMcpToolCallback(cb));
|
wrapped.add(new ReturnDirectMcpToolCallback(cb));
|
||||||
} else {
|
} else {
|
||||||
wrapped.add(cb);
|
wrapped.add(cb);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user