diff --git a/mateclaw-server/src/main/java/vip/mate/skill/controller/SkillController.java b/mateclaw-server/src/main/java/vip/mate/skill/controller/SkillController.java index 979c21fa..40b5da12 100644 --- a/mateclaw-server/src/main/java/vip/mate/skill/controller/SkillController.java +++ b/mateclaw-server/src/main/java/vip/mate/skill/controller/SkillController.java @@ -255,7 +255,28 @@ public class SkillController { @Operation(summary = "获取已启用技能列表") @GetMapping("/enabled") public R> 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 result = new ArrayList<>(skillService.listEnabledSkills()); + Set 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 = "按类型获取技能列表") diff --git a/mateclaw-server/src/main/java/vip/mate/tool/controller/ToolController.java b/mateclaw-server/src/main/java/vip/mate/tool/controller/ToolController.java index 93e3ff4c..e2074d3f 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/controller/ToolController.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/controller/ToolController.java @@ -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> listAvailable() { + return R.ok(availableToolService.listAvailable()); + } + @Operation(summary = "获取工具详情") @GetMapping("/{id}") public R get(@PathVariable Long id) { diff --git a/mateclaw-server/src/main/java/vip/mate/tool/model/AvailableToolDTO.java b/mateclaw-server/src/main/java/vip/mate/tool/model/AvailableToolDTO.java new file mode 100644 index 00000000..5eb1dc7a --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/tool/model/AvailableToolDTO.java @@ -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. + * + *

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(); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/tool/service/AvailableToolService.java b/mateclaw-server/src/main/java/vip/mate/tool/service/AvailableToolService.java new file mode 100644 index 00000000..a39a68cf --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/tool/service/AvailableToolService.java @@ -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}. + * + *

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()}. + * + *

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. + * + *

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. + * + *

Scope: 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 listAvailable() { + List out = new ArrayList<>(); + appendBuiltinTools(out); + appendMcpTools(out); + return out; + } + + private void appendBuiltinTools(List 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 out) { + List 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 out, McpServerEntity server) { + List 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 rawNames = new ArrayList<>(cached.size()); + for (CachedTool c : cached) rawNames.add(c.name); + List 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 parseCache(String json) { + if (json == null || json.isBlank()) { + return List.of(); + } + try { + JSONArray arr = JSONUtil.parseArray(json); + List 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) {} +}