feat(skill,tool): expose MCP-derived skills and tools through the agent picker endpoints

This commit is contained in:
matevip 2026-05-07 08:17:38 +08:00
parent 845b5bb1b1
commit 9adcc0a2a8
4 changed files with 311 additions and 1 deletions

View File

@ -255,7 +255,28 @@ public class SkillController {
@Operation(summary = "获取已启用技能列表")
@GetMapping("/enabled")
public R<List<SkillEntity>> listEnabled() {
return R.ok(skillService.listEnabledSkills());
// Mirror the merging the paginated /skills endpoint does so the agent
// edit picker (which calls this endpoint) sees MCP- and ACP-derived
// virtual skills alongside the persisted ones. The shadow base must
// include all real skill names including disabled ones so a
// disabled real skill correctly suppresses its same-named virtual
// twin, matching /skills and /counts.
List<SkillEntity> result = new ArrayList<>(skillService.listEnabledSkills());
Set<String> realNames = realSkillNames();
try {
result.addAll(filterShadowedVirtualSkills(
mcpSkillBridge.listMcpDerivedSkillEntities(), realNames));
} catch (Exception e) {
// Bridge failure must not 500 the picker same defensive stance as /counts.
}
try {
result.addAll(filterShadowedVirtualSkills(
acpSkillBridge.listAcpDerivedSkillEntities(), realNames));
} catch (Exception e) {
// Bridge failure must not 500 the picker same defensive stance as /counts.
}
return R.ok(result);
}
@Operation(summary = "按类型获取技能列表")

View File

@ -5,7 +5,9 @@ import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import vip.mate.common.result.R;
import vip.mate.tool.model.AvailableToolDTO;
import vip.mate.tool.model.ToolEntity;
import vip.mate.tool.service.AvailableToolService;
import vip.mate.tool.service.ToolService;
import java.util.List;
@ -22,6 +24,7 @@ import java.util.List;
public class ToolController {
private final ToolService toolService;
private final AvailableToolService availableToolService;
@Operation(summary = "获取工具列表")
@GetMapping
@ -35,6 +38,12 @@ public class ToolController {
return R.ok(toolService.listEnabledTools());
}
@Operation(summary = "获取员工可绑定的全部原子工具(含 MCP")
@GetMapping("/available")
public R<List<AvailableToolDTO>> listAvailable() {
return R.ok(availableToolService.listAvailable());
}
@Operation(summary = "获取工具详情")
@GetMapping("/{id}")
public R<ToolEntity> get(@PathVariable Long id) {

View File

@ -0,0 +1,96 @@
package vip.mate.tool.model;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* Picker DTO for the unified agent tool selector.
*
* <p>One row per atomic tool the agent can be bound to built-in tools
* appear under {@code source="builtin"}, MCP tools appear under
* {@code source="mcp"} and are grouped by their server. The {@link #name}
* field is the value the UI saves into {@code mate_agent_tool.tool_name};
* for MCP tools it is the prefixed callback name returned by the resolver
* so picker and runtime use the same key.
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class AvailableToolDTO {
/**
* Stable per-row identifier for the picker. The frontend uses this as
* the {@code v-for :key} so two rows with the same prefixed
* {@link #name} (e.g. a hash-collision pair) don't reuse each other's
* DOM state. Server-assigned, opaque to the client.
*/
private String rowId;
/** {@code "builtin"} or {@code "mcp"}. */
private String source;
/** MCP server id when {@code source == "mcp"}; null otherwise. */
private Long providerId;
/** Human-readable provider label — server display name for MCP, empty for builtin. */
private String providerName;
/** What the UI saves into {@code mate_agent_tool.tool_name}. */
private String name;
/** Original raw tool name as advertised upstream. UI shows this. */
private String rawName;
/** Tool description shown as the picker subtitle. */
private String description;
/** Group label for the picker UI section header (e.g. {@code "MCP · github"}). */
private String group;
/** Stable group key for collapse/expand state across renames. */
private String groupId;
/**
* {@code true} when the entry comes from the cache while the upstream
* MCP server is currently disconnected. The picker should grey it out;
* runtime callbacks for stale tools are absent so the LLM cannot call
* them either way.
*/
private boolean stale;
/**
* {@code false} the picker must disable selection. Currently set when
* a hash collision was detected for the same (serverId, prefixed-name)
* pair. {@code true} for everything that can be safely bound.
*/
private boolean available;
/**
* Machine-readable cause when {@link #available} is {@code false}.
* Examples: {@code "HASH_COLLISION"} (with the conflicting raw name in
* a follow-up message), {@code "DUPLICATE_RAW_NAME"}.
*/
private String unavailableReason;
public static AvailableToolDTO fromBuiltin(ToolEntity t) {
return AvailableToolDTO.builder()
// Built-in tool names are unique by ToolRegistry contract,
// so name suffices as a stable rowId.
.rowId("builtin#" + t.getName())
.source("builtin")
.providerId(null)
.providerName(null)
.name(t.getName())
.rawName(t.getName())
.description(t.getDescription() != null ? t.getDescription() : "")
.group("builtin")
.groupId("builtin")
.stale(false)
.available(true)
.unavailableReason(null)
.build();
}
}

View File

@ -0,0 +1,184 @@
package vip.mate.tool.service;
import cn.hutool.json.JSONArray;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import vip.mate.tool.mcp.model.McpServerEntity;
import vip.mate.tool.mcp.runtime.McpHashCollisionDetector;
import vip.mate.tool.mcp.service.McpServerService;
import vip.mate.tool.model.AvailableToolDTO;
import vip.mate.tool.model.ToolEntity;
import java.util.ArrayList;
import java.util.List;
/**
* Aggregator behind {@code GET /api/v1/tools/available}.
*
* <p>Returns one DTO per atomic tool the agent edit picker can offer:
* built-in tools (from {@link ToolService#listEnabledTools()}) plus every
* MCP tool persisted in {@link McpServerEntity#getToolsCacheJson()}.
*
* <p>Reads the cache rather than making a live MCP {@code listTools()}
* roundtrip so the picker stays fast and stable through brief upstream
* disconnects. The {@code stale} flag tells the UI when the entry came
* from a server that isn't currently connected.
*
* <p>Hash collisions are handled by reusing the same
* {@link McpHashCollisionDetector} the runtime uses, so an entry the
* runtime would skip never appears in the picker as bindable. Without
* this, the user could save a {@code mate_agent_tool.tool_name} that
* resolves to nothing at chat time.
*
* <p><b>Scope</b>: this aggregator covers the two tool sources users can
* bind from the agent edit screen built-in {@code @Tool} beans
* (persisted in {@code mate_tool}) and MCP-discovered tools (cached on
* the server row). Plugin-registered {@code ToolCallback} beans surfaced
* by other parts of the runtime are intentionally NOT listed here: those
* are not user-bindable from the agent picker today, and the picker's
* "saved name == runtime callback key" contract only needs to hold for
* the rows the picker actually emits. If plugin tools later become
* user-bindable, extend this aggregator (or accept that they go through
* a separate config path) see {@code AgentBindingService}'s
* {@code SYSTEM_LEVEL_TOOLS} carve-out for the same reasoning.
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class AvailableToolService {
private final ToolService toolService;
private final McpServerService mcpServerService;
public List<AvailableToolDTO> listAvailable() {
List<AvailableToolDTO> out = new ArrayList<>();
appendBuiltinTools(out);
appendMcpTools(out);
return out;
}
private void appendBuiltinTools(List<AvailableToolDTO> out) {
for (ToolEntity t : toolService.listEnabledTools()) {
if (t == null || t.getName() == null || t.getName().isBlank()) continue;
out.add(AvailableToolDTO.fromBuiltin(t));
}
}
private void appendMcpTools(List<AvailableToolDTO> out) {
List<McpServerEntity> servers;
try {
servers = mcpServerService.listEnabled();
} catch (Exception e) {
log.warn("AvailableToolService: listEnabled MCP servers failed: {}", e.getMessage());
return;
}
for (McpServerEntity s : servers) {
try {
appendOneMcpServer(out, s);
} catch (Exception e) {
log.warn("AvailableToolService: skipping MCP server {} due to: {}",
s.getId(), e.getMessage());
}
}
}
private void appendOneMcpServer(List<AvailableToolDTO> out, McpServerEntity server) {
List<CachedTool> cached = parseCache(server.getToolsCacheJson());
if (cached.isEmpty()) {
return;
}
boolean stale = !"connected".equalsIgnoreCase(nullSafe(server.getLastStatus()));
String groupLabel = "MCP · " + nullSafe(server.getName());
String groupKey = "mcp:" + server.getId();
// Run the collision check on the same raw-name list the runtime uses
// when it registers callbacks. Sharing this exact decision shape is
// what guarantees picker rows and AgentToolSet entries stay in sync.
List<String> rawNames = new ArrayList<>(cached.size());
for (CachedTool c : cached) rawNames.add(c.name);
List<McpHashCollisionDetector.Decision> decisions =
McpHashCollisionDetector.classify(server.getId(), rawNames);
// Walk cache and decisions in lockstep classify() drops blank
// raws, so advance the decision pointer only when the cache row's
// name is non-blank. This is the same alignment McpClientManager's
// wrapServerCallbacks uses; both must agree on which entry got
// which decision when the same raw appears more than once.
int dIdx = 0;
int rowIdx = 0;
for (CachedTool c : cached) {
if (c.name == null || c.name.isBlank()) {
continue;
}
if (dIdx >= decisions.size()) {
break;
}
McpHashCollisionDetector.Decision d = decisions.get(dIdx++);
out.add(buildMcpDto(server, groupLabel, groupKey, stale, c, d, rowIdx++));
}
}
private AvailableToolDTO buildMcpDto(McpServerEntity server, String groupLabel, String groupKey,
boolean stale, CachedTool cached,
McpHashCollisionDetector.Decision decision, int rowIdx) {
// rowId distinguishes rows that share the same prefixed `name` but
// arose from distinct raw entries (e.g. duplicate-raw, hash
// collision). Without it, a Vue v-for keyed on `name` reuses DOM
// for the unavailable twin and selection/disabled state goes
// stale. Including the raw and a per-server index makes the key
// stable across re-renders without depending on array order.
String rowId = groupKey + "#" + rowIdx + "#" + cached.name;
return AvailableToolDTO.builder()
.rowId(rowId)
.source("mcp")
.providerId(server.getId())
.providerName(server.getName())
.name(decision.prefixedName())
.rawName(cached.name)
.description(cached.description)
.group(groupLabel)
.groupId(groupKey)
.stale(stale)
.available(decision.bindable())
.unavailableReason(decision.unavailableReason())
.build();
}
/**
* Parse the {@code tools_cache_json} column written by
* {@link vip.mate.tool.mcp.service.McpServerService}. Returns an empty
* list when the column is null/blank/malformed the picker can render
* a server with no tools just as well as one with tools.
*/
private static List<CachedTool> parseCache(String json) {
if (json == null || json.isBlank()) {
return List.of();
}
try {
JSONArray arr = JSONUtil.parseArray(json);
List<CachedTool> out = new ArrayList<>(arr.size());
for (Object o : arr) {
if (!(o instanceof JSONObject jo)) continue;
String name = jo.getStr("name");
if (name == null || name.isBlank()) continue;
String desc = jo.getStr("description", "");
out.add(new CachedTool(name, desc != null ? desc : ""));
}
return out;
} catch (Exception e) {
log.debug("AvailableToolService: failed to parse tools_cache_json: {}", e.getMessage());
return List.of();
}
}
private static String nullSafe(String s) {
return s == null ? "" : s;
}
/** Trivial bag struct for the cached tool fields the picker needs. */
private record CachedTool(String name, String description) {}
}