mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
feat(skill,tool): expose MCP-derived skills and tools through the agent picker endpoints
This commit is contained in:
parent
845b5bb1b1
commit
9adcc0a2a8
@ -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 = "按类型获取技能列表")
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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) {}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user