mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-15 03:55:09 +08:00
fix(skill): synthesize SKILL.md for MCP-derived virtual skills (#136)
This commit is contained in:
parent
51c922f395
commit
130dfd5278
@ -145,6 +145,14 @@ public class SkillController {
|
|||||||
return filterShadowedVirtualSkills(virtualSkills, realSkillNames).size();
|
return filterShadowedVirtualSkills(virtualSkills, realSkillNames).size();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Keep only enabled rows — gates virtual skills into enabled-only endpoints. */
|
||||||
|
static List<SkillEntity> enabledOnly(List<SkillEntity> skills) {
|
||||||
|
if (skills == null || skills.isEmpty()) return List.of();
|
||||||
|
return skills.stream()
|
||||||
|
.filter(s -> s != null && Boolean.TRUE.equals(s.getEnabled()))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Keep MyBatis-Plus as the source of truth for DB pagination and append
|
* Keep MyBatis-Plus as the source of truth for DB pagination and append
|
||||||
* live virtual ACP/MCP rows after the DB rows. This produces one stable
|
* live virtual ACP/MCP rows after the DB rows. This produces one stable
|
||||||
@ -361,18 +369,21 @@ public class SkillController {
|
|||||||
// include all real skill names — including disabled ones — so a
|
// include all real skill names — including disabled ones — so a
|
||||||
// disabled real skill correctly suppresses its same-named virtual
|
// disabled real skill correctly suppresses its same-named virtual
|
||||||
// twin, matching /skills and /counts.
|
// twin, matching /skills and /counts.
|
||||||
|
// The bridges surface disabled MCP/ACP servers too (so the Skills
|
||||||
|
// page can show a toggled-off card); this endpoint is enabled-only,
|
||||||
|
// so the virtual rows are filtered to enabled before merging.
|
||||||
List<SkillEntity> result = new ArrayList<>(skillService.listEnabledSkills(workspaceId));
|
List<SkillEntity> result = new ArrayList<>(skillService.listEnabledSkills(workspaceId));
|
||||||
Set<String> realNames = realSkillNames(workspaceId);
|
Set<String> realNames = realSkillNames(workspaceId);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
result.addAll(filterShadowedVirtualSkills(
|
result.addAll(enabledOnly(filterShadowedVirtualSkills(
|
||||||
mcpSkillBridge.listMcpDerivedSkillEntities(), realNames));
|
mcpSkillBridge.listMcpDerivedSkillEntities(), realNames)));
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
// Bridge failure must not 500 the picker — same defensive stance as /counts.
|
// Bridge failure must not 500 the picker — same defensive stance as /counts.
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
result.addAll(filterShadowedVirtualSkills(
|
result.addAll(enabledOnly(filterShadowedVirtualSkills(
|
||||||
acpSkillBridge.listAcpDerivedSkillEntities(), realNames));
|
acpSkillBridge.listAcpDerivedSkillEntities(), realNames)));
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
// Bridge failure must not 500 the picker — same defensive stance as /counts.
|
// Bridge failure must not 500 the picker — same defensive stance as /counts.
|
||||||
}
|
}
|
||||||
@ -469,6 +480,13 @@ public class SkillController {
|
|||||||
@RequireWorkspaceRole("admin")
|
@RequireWorkspaceRole("admin")
|
||||||
public R<SkillEntity> toggle(@PathVariable Long id, @RequestParam boolean enabled,
|
public R<SkillEntity> toggle(@PathVariable Long id, @RequestParam boolean enabled,
|
||||||
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
|
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
|
||||||
|
// A virtual MCP skill mirrors an MCP server — toggling it enables /
|
||||||
|
// disables that server, keeping the Skills page and Settings ▸ MCP
|
||||||
|
// Connections in sync. ACP virtual skills have no such mapping and
|
||||||
|
// stay read-only via rejectVirtualSkillMutation below.
|
||||||
|
if (vip.mate.skill.mcp.McpSkillBridge.isVirtualMcpSkillId(id)) {
|
||||||
|
return R.ok(mcpSkillBridge.toggleVirtualSkill(id, enabled));
|
||||||
|
}
|
||||||
rejectVirtualSkillMutation(id);
|
rejectVirtualSkillMutation(id);
|
||||||
verifyResourceWorkspace(skillService.getSkill(id), workspaceId);
|
verifyResourceWorkspace(skillService.getSkill(id), workspaceId);
|
||||||
return R.ok(skillService.toggleSkill(id, enabled));
|
return R.ok(skillService.toggleSkill(id, enabled));
|
||||||
|
|||||||
@ -112,23 +112,45 @@ public class McpSkillBridge {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Snapshot every enabled MCP server as a virtual {@link SkillEntity}.
|
* Snapshot every MCP server as a virtual {@link SkillEntity}. Used by
|
||||||
* Used by the Skills list endpoint; rows are non-persistent and
|
* the Skills list endpoint; rows are non-persistent and regenerated on
|
||||||
* regenerated on each call.
|
* each call. Disabled servers are included so a skill the user toggled
|
||||||
|
* off still shows on the Skills page (as a disabled card) and can be
|
||||||
|
* toggled back on — {@code enabled} mirrors the server's flag.
|
||||||
*/
|
*/
|
||||||
public List<SkillEntity> listMcpDerivedSkillEntities() {
|
public List<SkillEntity> listMcpDerivedSkillEntities() {
|
||||||
return listEnabledServers().stream().map(this::serverToEntity).toList();
|
return listAllServers().stream().map(this::serverToEntity).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Snapshot every enabled MCP server as a virtual {@link ResolvedSkill}
|
* Snapshot every MCP server as a virtual {@link ResolvedSkill} with
|
||||||
* with synthesized manifest, ready to be merged into the runtime
|
* synthesized manifest, ready to be merged into the runtime status
|
||||||
* status feed. Status reflects connection health: OK → READY default
|
* feed. Status reflects connection health: OK → READY default feature;
|
||||||
* feature; ERROR / disconnected → SETUP_NEEDED with a diagnostic
|
* ERROR / disconnected → SETUP_NEEDED with a diagnostic missing-dependency
|
||||||
* missing-dependency entry.
|
* entry. Disabled servers are included for the admin status view; the
|
||||||
|
* active-skill gate ({@code SkillRuntimeService.passesActiveGate}) keeps
|
||||||
|
* them out of the agent runtime.
|
||||||
*/
|
*/
|
||||||
public List<ResolvedSkill> listMcpDerivedResolvedSkills() {
|
public List<ResolvedSkill> listMcpDerivedResolvedSkills() {
|
||||||
return listEnabledServers().stream().map(this::serverToResolved).toList();
|
return listAllServers().stream().map(this::serverToResolved).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enable or disable the MCP server behind a virtual MCP skill.
|
||||||
|
*
|
||||||
|
* <p>A virtual MCP skill has no {@code mate_skill} row — its enabled
|
||||||
|
* state is the underlying MCP server's {@code enabled} flag. Toggling
|
||||||
|
* the skill therefore toggles the server, which also connects or
|
||||||
|
* disconnects it. Returns the rebuilt virtual {@link SkillEntity}
|
||||||
|
* reflecting the new state.
|
||||||
|
*/
|
||||||
|
public SkillEntity toggleVirtualSkill(Long virtualId, boolean enabled) {
|
||||||
|
Long serverId = extractMcpServerId(virtualId);
|
||||||
|
if (serverId == null) {
|
||||||
|
throw new IllegalArgumentException("Not a virtual MCP skill id: " + virtualId);
|
||||||
|
}
|
||||||
|
McpServerEntity updated = mcpServerService.toggle(serverId, enabled);
|
||||||
|
return serverToEntity(updated);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -147,16 +169,17 @@ public class McpSkillBridge {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private List<McpServerEntity> listEnabledServers() {
|
private List<McpServerEntity> listAllServers() {
|
||||||
try {
|
try {
|
||||||
return mcpServerService.listEnabled();
|
return mcpServerService.listAll();
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.warn("MCP bridge could not list enabled servers: {}", e.getMessage());
|
log.warn("MCP bridge could not list servers: {}", e.getMessage());
|
||||||
return List.of();
|
return List.of();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private SkillEntity serverToEntity(McpServerEntity server) {
|
private SkillEntity serverToEntity(McpServerEntity server) {
|
||||||
|
List<McpToolDescriptor> tools = readToolDescriptors(server);
|
||||||
SkillEntity s = new SkillEntity();
|
SkillEntity s = new SkillEntity();
|
||||||
s.setId(virtualIdFor(server));
|
s.setId(virtualIdFor(server));
|
||||||
s.setName(slugForServer(server));
|
s.setName(slugForServer(server));
|
||||||
@ -171,13 +194,15 @@ public class McpSkillBridge {
|
|||||||
s.setBuiltin(false);
|
s.setBuiltin(false);
|
||||||
s.setTags("mcp");
|
s.setTags("mcp");
|
||||||
s.setSecurityScanStatus("PASSED"); // MCP servers don't go through SkillSecurityService
|
s.setSecurityScanStatus("PASSED"); // MCP servers don't go through SkillSecurityService
|
||||||
|
s.setSkillContent(buildSkillContent(server, tools));
|
||||||
s.setConfigJson(buildConfigJson(server));
|
s.setConfigJson(buildConfigJson(server));
|
||||||
s.setManifestJson(serializeManifest(buildManifestFrom(server, readToolRawNames(server))));
|
s.setManifestJson(serializeManifest(buildManifestFrom(server, toRawNames(tools))));
|
||||||
return s;
|
return s;
|
||||||
}
|
}
|
||||||
|
|
||||||
private ResolvedSkill serverToResolved(McpServerEntity server) {
|
private ResolvedSkill serverToResolved(McpServerEntity server) {
|
||||||
List<String> rawNames = readToolRawNames(server);
|
List<McpToolDescriptor> tools = readToolDescriptors(server);
|
||||||
|
List<String> rawNames = toRawNames(tools);
|
||||||
Map<String, String> toolDisplayNames = new LinkedHashMap<>();
|
Map<String, String> toolDisplayNames = new LinkedHashMap<>();
|
||||||
for (String raw : rawNames) {
|
for (String raw : rawNames) {
|
||||||
String prefixed = McpToolNameResolver.prefixedName(server.getId(), raw);
|
String prefixed = McpToolNameResolver.prefixedName(server.getId(), raw);
|
||||||
@ -203,7 +228,7 @@ public class McpSkillBridge {
|
|||||||
.id(virtualIdFor(server))
|
.id(virtualIdFor(server))
|
||||||
.name(slugForServer(server))
|
.name(slugForServer(server))
|
||||||
.description(buildDescription(server))
|
.description(buildDescription(server))
|
||||||
.content("") // no SKILL.md
|
.content(buildSkillContent(server, tools))
|
||||||
.source("mcp")
|
.source("mcp")
|
||||||
.skillDir(null)
|
.skillDir(null)
|
||||||
.configuredSkillDir(null)
|
.configuredSkillDir(null)
|
||||||
@ -293,24 +318,27 @@ public class McpSkillBridge {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve the raw tool name list for a server with cache-first / live-fallback
|
* Resolve the tool list for a server with cache-first / live-fallback
|
||||||
* semantics. Returns an empty list (never null) so the manifest builder
|
* semantics. Each entry carries the raw name and (when available) the
|
||||||
* stays simple.
|
* upstream description. Returns an empty list (never null) so callers
|
||||||
|
* stay simple.
|
||||||
*/
|
*/
|
||||||
private List<String> readToolRawNames(McpServerEntity server) {
|
private List<McpToolDescriptor> readToolDescriptors(McpServerEntity server) {
|
||||||
List<String> fromCache = parseCachedToolNames(server.getToolsCacheJson());
|
List<McpToolDescriptor> fromCache = parseCachedToolDescriptors(server.getToolsCacheJson());
|
||||||
if (!fromCache.isEmpty()) {
|
if (!fromCache.isEmpty()) {
|
||||||
return fromCache;
|
return fromCache;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
List<McpSchema.Tool> discovered = mcpClientManager.getServerTools(server.getId());
|
List<McpSchema.Tool> discovered = mcpClientManager.getServerTools(server.getId());
|
||||||
List<String> names = new ArrayList<>(discovered.size());
|
List<McpToolDescriptor> out = new ArrayList<>(discovered.size());
|
||||||
for (McpSchema.Tool t : discovered) {
|
for (McpSchema.Tool t : discovered) {
|
||||||
if (t == null) continue;
|
if (t == null) continue;
|
||||||
String n = t.name();
|
String n = t.name();
|
||||||
if (n != null && !n.isBlank()) names.add(n);
|
if (n != null && !n.isBlank()) {
|
||||||
|
out.add(new McpToolDescriptor(n, t.description()));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return names;
|
return out;
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.debug("MCP bridge manifest build: getServerTools({}) failed: {}",
|
log.debug("MCP bridge manifest build: getServerTools({}) failed: {}",
|
||||||
server.getId(), e.getMessage());
|
server.getId(), e.getMessage());
|
||||||
@ -320,22 +348,25 @@ public class McpSkillBridge {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse the {@code tools_cache_json} column written by
|
* Parse the {@code tools_cache_json} column written by
|
||||||
* {@code McpServerService} after each successful connect. Returns an
|
* {@code McpServerService} after each successful connect — an array of
|
||||||
* empty list if the column is null/blank/malformed — the bridge is
|
* {@code {name, description, inputSchema}} entries. Returns an empty
|
||||||
* required to keep working when the cache hasn't been populated yet
|
* list if the column is null/blank/malformed — the bridge is required
|
||||||
* (e.g. first-ever connect just succeeded a moment ago).
|
* to keep working when the cache hasn't been populated yet (e.g.
|
||||||
|
* first-ever connect just succeeded a moment ago).
|
||||||
*/
|
*/
|
||||||
private List<String> parseCachedToolNames(String json) {
|
private List<McpToolDescriptor> parseCachedToolDescriptors(String json) {
|
||||||
if (json == null || json.isBlank()) {
|
if (json == null || json.isBlank()) {
|
||||||
return List.of();
|
return List.of();
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
cn.hutool.json.JSONArray arr = cn.hutool.json.JSONUtil.parseArray(json);
|
cn.hutool.json.JSONArray arr = cn.hutool.json.JSONUtil.parseArray(json);
|
||||||
List<String> out = new ArrayList<>(arr.size());
|
List<McpToolDescriptor> out = new ArrayList<>(arr.size());
|
||||||
for (Object obj : arr) {
|
for (Object obj : arr) {
|
||||||
if (!(obj instanceof cn.hutool.json.JSONObject jo)) continue;
|
if (!(obj instanceof cn.hutool.json.JSONObject jo)) continue;
|
||||||
String name = jo.getStr("name");
|
String name = jo.getStr("name");
|
||||||
if (name != null && !name.isBlank()) out.add(name);
|
if (name != null && !name.isBlank()) {
|
||||||
|
out.add(new McpToolDescriptor(name, jo.getStr("description")));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
@ -344,6 +375,80 @@ public class McpSkillBridge {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static List<String> toRawNames(List<McpToolDescriptor> tools) {
|
||||||
|
List<String> names = new ArrayList<>(tools.size());
|
||||||
|
for (McpToolDescriptor t : tools) {
|
||||||
|
names.add(t.name());
|
||||||
|
}
|
||||||
|
return names;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Synthesize a SKILL.md body for an MCP-derived virtual skill.
|
||||||
|
*
|
||||||
|
* <p>Persisted and uploaded skills ship a hand-written SKILL.md that the
|
||||||
|
* agent serves on demand through {@code readSkillFile}; it tells the
|
||||||
|
* model what the skill is for and how to drive it. An MCP-derived skill
|
||||||
|
* has no such file — the upstream server only exposes a tool list — so
|
||||||
|
* without a synthesized body {@code readSkillFile} returns "content not
|
||||||
|
* available" and the model has nothing beyond the one-line description
|
||||||
|
* to reason about.
|
||||||
|
*
|
||||||
|
* <p>This builds an equivalent body from the live tool snapshot: a
|
||||||
|
* one-line summary, the tool catalog with per-tool descriptions, and a
|
||||||
|
* short usage note. Regenerated on every list call, so it tracks the
|
||||||
|
* upstream tool set with no persistence step.
|
||||||
|
*/
|
||||||
|
private String buildSkillContent(McpServerEntity server, List<McpToolDescriptor> tools) {
|
||||||
|
String displayName = displayName(server);
|
||||||
|
StringBuilder md = new StringBuilder();
|
||||||
|
md.append("# ").append(displayName).append("\n\n");
|
||||||
|
md.append(buildDescription(server)).append("\n\n");
|
||||||
|
md.append("This capability is provided by the MCP server **").append(displayName).append("**");
|
||||||
|
String transport = nullSafe(server.getTransport());
|
||||||
|
if (!transport.isBlank()) {
|
||||||
|
md.append(" (").append(transport).append(" transport)");
|
||||||
|
}
|
||||||
|
md.append(". Its tools are available to you as ordinary function calls — ")
|
||||||
|
.append("invoke them directly by name; no shell or scripts are involved.\n\n");
|
||||||
|
|
||||||
|
md.append("## Available Tools\n\n");
|
||||||
|
if (tools.isEmpty()) {
|
||||||
|
md.append("The tool list is not available yet. The MCP server may be ")
|
||||||
|
.append("disconnected or still starting up — check its status in ")
|
||||||
|
.append("Settings ▸ MCP Connections.\n");
|
||||||
|
return md.toString();
|
||||||
|
}
|
||||||
|
md.append("This server exposes ").append(tools.size())
|
||||||
|
.append(tools.size() == 1 ? " tool:\n\n" : " tools:\n\n");
|
||||||
|
for (McpToolDescriptor t : tools) {
|
||||||
|
md.append("- **").append(t.name()).append("**");
|
||||||
|
String desc = oneLine(t.description());
|
||||||
|
if (!desc.isBlank()) {
|
||||||
|
md.append(" — ").append(desc);
|
||||||
|
}
|
||||||
|
md.append("\n");
|
||||||
|
}
|
||||||
|
md.append("\n## Usage Notes\n\n");
|
||||||
|
md.append("- These tools appear in your tool list under `mcp_`-prefixed names; ")
|
||||||
|
.append("pick whichever one matches the user's request.\n");
|
||||||
|
md.append("- If a call fails with a connection error, the MCP server is likely ")
|
||||||
|
.append("disconnected — it can be reconnected in Settings ▸ MCP Connections.\n");
|
||||||
|
return md.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Collapse whitespace and clamp a tool description to a prompt-friendly length. */
|
||||||
|
private static String oneLine(String s) {
|
||||||
|
if (s == null) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
String collapsed = s.replaceAll("\\s+", " ").trim();
|
||||||
|
return collapsed.length() > 200 ? collapsed.substring(0, 200) + "…" : collapsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Minimal MCP tool projection: just what the manifest and SKILL.md body need. */
|
||||||
|
private record McpToolDescriptor(String name, String description) {}
|
||||||
|
|
||||||
private String slugify(String raw) {
|
private String slugify(String raw) {
|
||||||
if (raw == null) return "";
|
if (raw == null) return "";
|
||||||
return raw.toLowerCase(Locale.ROOT).replaceAll("[^a-z0-9_-]", "-");
|
return raw.toLowerCase(Locale.ROOT).replaceAll("[^a-z0-9_-]", "-");
|
||||||
|
|||||||
@ -135,6 +135,22 @@ class SkillControllerListEnabledTest {
|
|||||||
assertTrue(response.getData().stream().anyMatch(s -> "github".equals(s.getName())));
|
assertTrue(response.getData().stream().anyMatch(s -> "github".equals(s.getName())));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("listEnabled excludes a disabled MCP virtual skill")
|
||||||
|
void excludesDisabledVirtualMcpSkill() {
|
||||||
|
// The bridge now surfaces disabled MCP servers too (so the Skills
|
||||||
|
// page can show a toggled-off card); the enabled-only picker must
|
||||||
|
// filter them back out.
|
||||||
|
SkillEntity disabledMcp = skill("github", "mcp");
|
||||||
|
disabledMcp.setEnabled(false);
|
||||||
|
when(mcpSkillBridge.listMcpDerivedSkillEntities()).thenReturn(List.of(disabledMcp));
|
||||||
|
|
||||||
|
R<List<SkillEntity>> response = controller.listEnabled(null);
|
||||||
|
|
||||||
|
assertEquals(0, response.getData().size(),
|
||||||
|
"a disabled MCP virtual skill must not appear in the enabled-only picker");
|
||||||
|
}
|
||||||
|
|
||||||
private static SkillEntity skill(String name, String type) {
|
private static SkillEntity skill(String name, String type) {
|
||||||
SkillEntity s = new SkillEntity();
|
SkillEntity s = new SkillEntity();
|
||||||
s.setName(name);
|
s.setName(name);
|
||||||
|
|||||||
@ -2,19 +2,25 @@ package vip.mate.skill.controller;
|
|||||||
|
|
||||||
import org.junit.jupiter.api.DisplayName;
|
import org.junit.jupiter.api.DisplayName;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
import vip.mate.common.result.R;
|
||||||
import vip.mate.exception.MateClawException;
|
import vip.mate.exception.MateClawException;
|
||||||
import vip.mate.skill.acp.AcpSkillBridge;
|
import vip.mate.skill.acp.AcpSkillBridge;
|
||||||
import vip.mate.skill.mcp.McpSkillBridge;
|
import vip.mate.skill.mcp.McpSkillBridge;
|
||||||
import vip.mate.skill.model.SkillEntity;
|
import vip.mate.skill.model.SkillEntity;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
import static org.mockito.Mockito.mock;
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mutation paths refuse virtual MCP/ACP skill ids upfront so the user
|
* The edit / delete mutation paths refuse virtual MCP/ACP skill ids upfront
|
||||||
* gets a clear redirect to the connection page instead of the previous
|
* so the user gets a clear redirect to the connection page instead of the
|
||||||
* "技能不存在" 500 surfacing from a doomed mate_skill lookup.
|
* old "技能不存在" 500 from a doomed mate_skill lookup. Toggle is the one
|
||||||
|
* exception: a virtual MCP skill mirrors an MCP server, so toggling it
|
||||||
|
* forwards to that server's enable/disable.
|
||||||
*/
|
*/
|
||||||
class SkillControllerVirtualGuardTest {
|
class SkillControllerVirtualGuardTest {
|
||||||
|
|
||||||
@ -45,14 +51,42 @@ class SkillControllerVirtualGuardTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("delete / toggle / rescan all reject virtual ids the same way")
|
@DisplayName("delete / rescan still reject virtual ids the same way")
|
||||||
void mutationFamilyAllGuarded() {
|
void mutationFamilyAllGuarded() {
|
||||||
long virtualId = McpSkillBridge.VIRTUAL_ID_BASE + 42L;
|
long virtualId = McpSkillBridge.VIRTUAL_ID_BASE + 42L;
|
||||||
assertThrows(MateClawException.class, () -> controller.delete(virtualId, null));
|
assertThrows(MateClawException.class, () -> controller.delete(virtualId, null));
|
||||||
assertThrows(MateClawException.class, () -> controller.toggle(virtualId, true, null));
|
|
||||||
assertThrows(MateClawException.class, () -> controller.rescan(virtualId, null));
|
assertThrows(MateClawException.class, () -> controller.rescan(virtualId, null));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("toggle on a virtual MCP skill forwards to the bridge instead of rejecting")
|
||||||
|
void toggleForwardsVirtualMcpToBridge() {
|
||||||
|
McpSkillBridge bridge = mock(McpSkillBridge.class);
|
||||||
|
SkillController c = new SkillController(
|
||||||
|
null, null, null, null, null, null, null, null, null, null, null,
|
||||||
|
bridge, null);
|
||||||
|
long virtualMcpId = McpSkillBridge.VIRTUAL_ID_BASE + 42L;
|
||||||
|
SkillEntity toggled = new SkillEntity();
|
||||||
|
toggled.setName("github");
|
||||||
|
toggled.setEnabled(false);
|
||||||
|
when(bridge.toggleVirtualSkill(virtualMcpId, false)).thenReturn(toggled);
|
||||||
|
|
||||||
|
R<SkillEntity> resp = c.toggle(virtualMcpId, false, null);
|
||||||
|
|
||||||
|
verify(bridge).toggleVirtualSkill(virtualMcpId, false);
|
||||||
|
assertEquals("github", resp.getData().getName());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("toggle on a virtual ACP skill is still rejected — no MCP-server mapping")
|
||||||
|
void toggleRejectsVirtualAcp() {
|
||||||
|
long virtualAcpId = AcpSkillBridge.VIRTUAL_ID_BASE + 7L;
|
||||||
|
assertTrue(AcpSkillBridge.isVirtualAcpSkillId(virtualAcpId),
|
||||||
|
"test fixture id is not in ACP virtual range; ACP base layout changed?");
|
||||||
|
assertThrows(MateClawException.class,
|
||||||
|
() -> controller.toggle(virtualAcpId, true, null));
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("real skill ids fall through to the service (no false-positive guard)")
|
@DisplayName("real skill ids fall through to the service (no false-positive guard)")
|
||||||
void realIdNotGuarded() {
|
void realIdNotGuarded() {
|
||||||
|
|||||||
@ -0,0 +1,184 @@
|
|||||||
|
package vip.mate.skill.mcp;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.DisplayName;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import vip.mate.skill.model.SkillEntity;
|
||||||
|
import vip.mate.skill.runtime.model.ResolvedSkill;
|
||||||
|
import vip.mate.tool.mcp.model.McpServerEntity;
|
||||||
|
import vip.mate.tool.mcp.runtime.McpClientManager;
|
||||||
|
import vip.mate.tool.mcp.service.McpServerService;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asserts that MCP-derived virtual skills carry a synthesized SKILL.md body
|
||||||
|
* instead of the empty string. Without it, {@code readSkillFile("SKILL.md")}
|
||||||
|
* returns "content not available" and the agent cannot reason about the
|
||||||
|
* MCP server's tools.
|
||||||
|
*/
|
||||||
|
class McpSkillBridgeContentTest {
|
||||||
|
|
||||||
|
private McpServerService mcpServerService;
|
||||||
|
private McpClientManager mcpClientManager;
|
||||||
|
private McpSkillBridge bridge;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
mcpServerService = mock(McpServerService.class);
|
||||||
|
mcpClientManager = mock(McpClientManager.class);
|
||||||
|
bridge = new McpSkillBridge(mcpServerService, mcpClientManager, new ObjectMapper());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("resolved skill carries a non-empty SKILL.md listing the server's tools")
|
||||||
|
void resolvedSkillHasSynthesizedContent() {
|
||||||
|
McpServerEntity server = newServer(42L, "github");
|
||||||
|
server.setToolsCacheJson(toolsJson(
|
||||||
|
"create_issue", "Open a new issue in a repository",
|
||||||
|
"list_issues", "List issues filtered by state and labels"));
|
||||||
|
when(mcpServerService.listAll()).thenReturn(List.of(server));
|
||||||
|
|
||||||
|
ResolvedSkill resolved = bridge.listMcpDerivedResolvedSkills().get(0);
|
||||||
|
|
||||||
|
String content = resolved.getContent();
|
||||||
|
assertFalse(content == null || content.isBlank(), "SKILL.md content must not be empty");
|
||||||
|
assertTrue(content.contains("github"), "content should name the MCP server");
|
||||||
|
assertTrue(content.contains("create_issue"), "content should list the create_issue tool");
|
||||||
|
assertTrue(content.contains("list_issues"), "content should list the list_issues tool");
|
||||||
|
assertTrue(content.contains("Open a new issue in a repository"),
|
||||||
|
"content should carry the upstream tool description");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("virtual SkillEntity carries skillContent so the detail drawer can render it")
|
||||||
|
void entityHasSynthesizedSkillContent() {
|
||||||
|
McpServerEntity server = newServer(42L, "github");
|
||||||
|
server.setToolsCacheJson(toolsJson("create_issue", "Open a new issue"));
|
||||||
|
when(mcpServerService.listAll()).thenReturn(List.of(server));
|
||||||
|
|
||||||
|
SkillEntity entity = bridge.listMcpDerivedSkillEntities().get(0);
|
||||||
|
|
||||||
|
assertFalse(entity.getSkillContent() == null || entity.getSkillContent().isBlank(),
|
||||||
|
"skillContent must be populated for MCP-derived skills");
|
||||||
|
assertTrue(entity.getSkillContent().contains("create_issue"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("a server with no known tools still gets a content body that explains the gap")
|
||||||
|
void emptyToolListStillProducesContent() {
|
||||||
|
McpServerEntity server = newServer(42L, "github");
|
||||||
|
server.setToolsCacheJson("");
|
||||||
|
server.setLastStatus("disconnected");
|
||||||
|
when(mcpServerService.listAll()).thenReturn(List.of(server));
|
||||||
|
when(mcpClientManager.getServerTools(42L)).thenReturn(List.of());
|
||||||
|
|
||||||
|
ResolvedSkill resolved = bridge.listMcpDerivedResolvedSkills().get(0);
|
||||||
|
|
||||||
|
String content = resolved.getContent();
|
||||||
|
assertFalse(content == null || content.isBlank(),
|
||||||
|
"content must not be empty even when the tool list is unavailable");
|
||||||
|
assertTrue(content.contains("MCP Connections"),
|
||||||
|
"content should point the user at the MCP Connections page");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("tool descriptions are clamped to a single prompt-friendly line")
|
||||||
|
void longDescriptionsAreClampedToOneLine() {
|
||||||
|
McpServerEntity server = newServer(42L, "github");
|
||||||
|
String longDesc = "x".repeat(400);
|
||||||
|
server.setToolsCacheJson(toolsJson("create_issue", longDesc));
|
||||||
|
when(mcpServerService.listAll()).thenReturn(List.of(server));
|
||||||
|
|
||||||
|
ResolvedSkill resolved = bridge.listMcpDerivedResolvedSkills().get(0);
|
||||||
|
|
||||||
|
String toolLine = resolved.getContent().lines()
|
||||||
|
.filter(l -> l.startsWith("- **create_issue**"))
|
||||||
|
.findFirst()
|
||||||
|
.orElseThrow();
|
||||||
|
assertTrue(toolLine.length() < longDesc.length(),
|
||||||
|
"an over-long description should be truncated, got: " + toolLine.length());
|
||||||
|
assertTrue(toolLine.endsWith("…"), "truncated descriptions should end with an ellipsis");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static McpServerEntity newServer(long id, String name) {
|
||||||
|
McpServerEntity s = new McpServerEntity();
|
||||||
|
s.setId(id);
|
||||||
|
s.setName(name);
|
||||||
|
s.setEnabled(true);
|
||||||
|
s.setTransport("stdio");
|
||||||
|
s.setCommand("/usr/bin/echo");
|
||||||
|
s.setLastStatus("connected");
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Builds a tools_cache_json array from alternating name/description pairs. */
|
||||||
|
private static String toolsJson(String... nameDescPairs) {
|
||||||
|
StringBuilder sb = new StringBuilder("[");
|
||||||
|
for (int i = 0; i + 1 < nameDescPairs.length; i += 2) {
|
||||||
|
if (i > 0) {
|
||||||
|
sb.append(",");
|
||||||
|
}
|
||||||
|
sb.append("{\"name\":\"").append(nameDescPairs[i])
|
||||||
|
.append("\",\"description\":\"").append(nameDescPairs[i + 1])
|
||||||
|
.append("\",\"inputSchema\":{}}");
|
||||||
|
}
|
||||||
|
sb.append("]");
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("two name/description pairs round-trip into the catalog")
|
||||||
|
void multipleToolsRenderAsCatalogRows() {
|
||||||
|
McpServerEntity server = newServer(7L, "filesystem");
|
||||||
|
server.setToolsCacheJson(toolsJson(
|
||||||
|
"read_file", "Read the contents of a file",
|
||||||
|
"write_file", "Write content to a file"));
|
||||||
|
when(mcpServerService.listAll()).thenReturn(List.of(server));
|
||||||
|
|
||||||
|
String content = bridge.listMcpDerivedResolvedSkills().get(0).getContent();
|
||||||
|
|
||||||
|
long rows = content.lines().filter(l -> l.startsWith("- **")).count();
|
||||||
|
assertEquals(2, rows, "each MCP tool should be one catalog row");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("toggleVirtualSkill forwards to mcpServerService.toggle and reflects the new state")
|
||||||
|
void toggleVirtualSkillForwardsToServer() {
|
||||||
|
McpServerEntity disabled = newServer(42L, "github");
|
||||||
|
disabled.setEnabled(false);
|
||||||
|
long virtualId = McpSkillBridge.virtualIdFor(disabled);
|
||||||
|
|
||||||
|
McpServerEntity enabledAfter = newServer(42L, "github");
|
||||||
|
enabledAfter.setEnabled(true);
|
||||||
|
when(mcpServerService.toggle(42L, true)).thenReturn(enabledAfter);
|
||||||
|
|
||||||
|
SkillEntity result = bridge.toggleVirtualSkill(virtualId, true);
|
||||||
|
|
||||||
|
verify(mcpServerService).toggle(42L, true);
|
||||||
|
assertEquals("github", result.getName());
|
||||||
|
assertEquals(Boolean.TRUE, result.getEnabled());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("a disabled MCP server still surfaces as a (disabled) virtual skill row")
|
||||||
|
void disabledServerStillListedAsDisabledSkill() {
|
||||||
|
McpServerEntity server = newServer(42L, "github");
|
||||||
|
server.setEnabled(false);
|
||||||
|
when(mcpServerService.listAll()).thenReturn(List.of(server));
|
||||||
|
|
||||||
|
List<SkillEntity> entities = bridge.listMcpDerivedSkillEntities();
|
||||||
|
|
||||||
|
assertEquals(1, entities.size(),
|
||||||
|
"disabled MCP servers must still appear so the toggle can be flipped back on");
|
||||||
|
assertEquals(Boolean.FALSE, entities.get(0).getEnabled());
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -47,7 +47,7 @@ class McpSkillBridgeManifestTest {
|
|||||||
void allowedToolsArePrefixed() {
|
void allowedToolsArePrefixed() {
|
||||||
McpServerEntity server = newServer(42L, "github");
|
McpServerEntity server = newServer(42L, "github");
|
||||||
server.setToolsCacheJson(toolsJson("create_issue", "list_issues"));
|
server.setToolsCacheJson(toolsJson("create_issue", "list_issues"));
|
||||||
when(mcpServerService.listEnabled()).thenReturn(List.of(server));
|
when(mcpServerService.listAll()).thenReturn(List.of(server));
|
||||||
|
|
||||||
SkillEntity entity = bridge.listMcpDerivedSkillEntities().get(0);
|
SkillEntity entity = bridge.listMcpDerivedSkillEntities().get(0);
|
||||||
|
|
||||||
@ -65,7 +65,7 @@ class McpSkillBridgeManifestTest {
|
|||||||
void readsFromCacheFirst() {
|
void readsFromCacheFirst() {
|
||||||
McpServerEntity server = newServer(42L, "github");
|
McpServerEntity server = newServer(42L, "github");
|
||||||
server.setToolsCacheJson(toolsJson("create_issue"));
|
server.setToolsCacheJson(toolsJson("create_issue"));
|
||||||
when(mcpServerService.listEnabled()).thenReturn(List.of(server));
|
when(mcpServerService.listAll()).thenReturn(List.of(server));
|
||||||
|
|
||||||
bridge.listMcpDerivedSkillEntities();
|
bridge.listMcpDerivedSkillEntities();
|
||||||
|
|
||||||
@ -77,7 +77,7 @@ class McpSkillBridgeManifestTest {
|
|||||||
void fallsBackToLiveWhenCacheMissing() {
|
void fallsBackToLiveWhenCacheMissing() {
|
||||||
McpServerEntity server = newServer(42L, "github");
|
McpServerEntity server = newServer(42L, "github");
|
||||||
server.setToolsCacheJson(null); // first-ever connect just happened, cache not yet written
|
server.setToolsCacheJson(null); // first-ever connect just happened, cache not yet written
|
||||||
when(mcpServerService.listEnabled()).thenReturn(List.of(server));
|
when(mcpServerService.listAll()).thenReturn(List.of(server));
|
||||||
when(mcpClientManager.getServerTools(42L)).thenReturn(List.of(
|
when(mcpClientManager.getServerTools(42L)).thenReturn(List.of(
|
||||||
fakeTool("create_issue"),
|
fakeTool("create_issue"),
|
||||||
fakeTool("list_issues")));
|
fakeTool("list_issues")));
|
||||||
@ -94,7 +94,7 @@ class McpSkillBridgeManifestTest {
|
|||||||
McpServerEntity server = newServer(42L, "github");
|
McpServerEntity server = newServer(42L, "github");
|
||||||
server.setToolsCacheJson("");
|
server.setToolsCacheJson("");
|
||||||
server.setLastStatus("disconnected");
|
server.setLastStatus("disconnected");
|
||||||
when(mcpServerService.listEnabled()).thenReturn(List.of(server));
|
when(mcpServerService.listAll()).thenReturn(List.of(server));
|
||||||
when(mcpClientManager.getServerTools(42L)).thenReturn(List.of());
|
when(mcpClientManager.getServerTools(42L)).thenReturn(List.of());
|
||||||
|
|
||||||
SkillEntity entity = bridge.listMcpDerivedSkillEntities().get(0);
|
SkillEntity entity = bridge.listMcpDerivedSkillEntities().get(0);
|
||||||
@ -150,7 +150,7 @@ class McpSkillBridgeManifestTest {
|
|||||||
McpServerEntity b = newServer(43L, "客户档案信息查询服务");
|
McpServerEntity b = newServer(43L, "客户档案信息查询服务");
|
||||||
a.setToolsCacheJson(toolsJson("search"));
|
a.setToolsCacheJson(toolsJson("search"));
|
||||||
b.setToolsCacheJson(toolsJson("search"));
|
b.setToolsCacheJson(toolsJson("search"));
|
||||||
when(mcpServerService.listEnabled()).thenReturn(List.of(a, b));
|
when(mcpServerService.listAll()).thenReturn(List.of(a, b));
|
||||||
|
|
||||||
List<SkillEntity> entities = bridge.listMcpDerivedSkillEntities();
|
List<SkillEntity> entities = bridge.listMcpDerivedSkillEntities();
|
||||||
|
|
||||||
@ -167,7 +167,7 @@ class McpSkillBridgeManifestTest {
|
|||||||
void asciiNamePreservesExistingSlug() {
|
void asciiNamePreservesExistingSlug() {
|
||||||
McpServerEntity server = newServer(42L, "GitHub");
|
McpServerEntity server = newServer(42L, "GitHub");
|
||||||
server.setToolsCacheJson(toolsJson("create_issue"));
|
server.setToolsCacheJson(toolsJson("create_issue"));
|
||||||
when(mcpServerService.listEnabled()).thenReturn(List.of(server));
|
when(mcpServerService.listAll()).thenReturn(List.of(server));
|
||||||
|
|
||||||
SkillEntity entity = bridge.listMcpDerivedSkillEntities().get(0);
|
SkillEntity entity = bridge.listMcpDerivedSkillEntities().get(0);
|
||||||
|
|
||||||
@ -181,7 +181,7 @@ class McpSkillBridgeManifestTest {
|
|||||||
a.setToolsCacheJson(toolsJson("search"));
|
a.setToolsCacheJson(toolsJson("search"));
|
||||||
McpServerEntity b = newServer(43L, "filesystem");
|
McpServerEntity b = newServer(43L, "filesystem");
|
||||||
b.setToolsCacheJson(toolsJson("search"));
|
b.setToolsCacheJson(toolsJson("search"));
|
||||||
when(mcpServerService.listEnabled()).thenReturn(List.of(a, b));
|
when(mcpServerService.listAll()).thenReturn(List.of(a, b));
|
||||||
|
|
||||||
List<SkillEntity> entities = bridge.listMcpDerivedSkillEntities();
|
List<SkillEntity> entities = bridge.listMcpDerivedSkillEntities();
|
||||||
|
|
||||||
|
|||||||
@ -98,12 +98,16 @@
|
|||||||
<!-- RFC-042 §2.2.4 — slug under display name when they differ -->
|
<!-- RFC-042 §2.2.4 — slug under display name when they differ -->
|
||||||
<div v-if="hasI18nName(skill)" class="skill-slug">{{ skill.name }}</div>
|
<div v-if="hasI18nName(skill)" class="skill-slug">{{ skill.name }}</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Issue #83: virtual MCP/ACP skills are view-only mirrors of the
|
<!-- MCP-derived skills accept the enable/disable toggle — it
|
||||||
underlying MCP/ACP server row, with no mate_skill row to flip.
|
forwards to the underlying MCP server connection, so the
|
||||||
Hiding the toggle here matches how the configure / delete
|
Skills page and Settings ▸ MCP Connections stay in sync.
|
||||||
buttons are gated below; users enable/disable from the
|
ACP-derived skills have no such mapping and stay read-only
|
||||||
Settings ▸ MCP connection page instead. -->
|
(padlock); configure / delete are gated for both below. -->
|
||||||
<label v-if="!isSkillRowVirtual(skill)" class="toggle-switch" @click.stop>
|
<label
|
||||||
|
v-if="!isSkillRowVirtual(skill) || isMcpSkillRow(skill)"
|
||||||
|
class="toggle-switch"
|
||||||
|
@click.stop
|
||||||
|
>
|
||||||
<input type="checkbox" :checked="skill.enabled" @change="toggleSkill(skill)" />
|
<input type="checkbox" :checked="skill.enabled" @change="toggleSkill(skill)" />
|
||||||
<span class="toggle-slider"></span>
|
<span class="toggle-slider"></span>
|
||||||
</label>
|
</label>
|
||||||
@ -750,17 +754,22 @@ const editBodyForm = ref<{ skillContent: string; sourceCode: string }>({
|
|||||||
* at creation time. Everything else is filled in via the drawer. */
|
* at creation time. Everything else is filled in via the drawer. */
|
||||||
const newForm = ref<{ name: string; description: string; icon: string }>({ name: '', description: '', icon: '' })
|
const newForm = ref<{ name: string; description: string; icon: string }>({ name: '', description: '', icon: '' })
|
||||||
|
|
||||||
/** Virtual MCP-derived skills synthesize their id from
|
/** Virtual MCP/ACP-derived skills are read-only mirrors of a connection
|
||||||
* {@link McpSkillBridge#VIRTUAL_ID_BASE} (= 9e18). The DB update path
|
* row — there is no mate_skill row to edit or delete, so the drawer hides
|
||||||
* doesn't know about them, so the drawer hides the Edit affordance.
|
* the Edit affordance. The bridge encodes their ids with the sign bit set,
|
||||||
* Using string-length is robust against JS number precision loss past 2^53. */
|
* so a virtual id is always negative while real Snowflake ids are always
|
||||||
|
* positive. Checking the leading '-' is precision-safe — no Number()
|
||||||
|
* round-trip that would corrupt ids past 2^53. */
|
||||||
function isVirtualSkillId(id: unknown): boolean {
|
function isVirtualSkillId(id: unknown): boolean {
|
||||||
if (id === null || id === undefined) return false
|
if (id === null || id === undefined) return false
|
||||||
const idStr = String(id)
|
return String(id).trim().startsWith('-')
|
||||||
return idStr.length >= 19 && idStr.startsWith('9')
|
|
||||||
}
|
}
|
||||||
/** Per-row check used by the card-level configure / delete buttons. */
|
/** Per-row check used by the card-level configure / delete buttons. */
|
||||||
const isSkillRowVirtual = (skill: { id?: unknown } | null | undefined) => isVirtualSkillId(skill?.id)
|
const isSkillRowVirtual = (skill: { id?: unknown } | null | undefined) => isVirtualSkillId(skill?.id)
|
||||||
|
/** MCP-derived rows stay read-only for edit/delete, but their enable/disable
|
||||||
|
* toggle is honored — it forwards to the underlying MCP server connection. */
|
||||||
|
const isMcpSkillRow = (skill: { skillType?: unknown } | null | undefined) =>
|
||||||
|
skill?.skillType === 'mcp'
|
||||||
const isVirtualSkill = computed(() => isVirtualSkillId(detailSkill.value?.id))
|
const isVirtualSkill = computed(() => isVirtualSkillId(detailSkill.value?.id))
|
||||||
const isBuiltinDetail = computed(() => detailSkill.value?.skillType === 'builtin' || !!detailSkill.value?.builtin)
|
const isBuiltinDetail = computed(() => detailSkill.value?.skillType === 'builtin' || !!detailSkill.value?.builtin)
|
||||||
|
|
||||||
@ -1212,11 +1221,11 @@ async function deleteSkill(idOrSkill: string | number | Skill) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function toggleSkill(skill: Skill) {
|
async function toggleSkill(skill: Skill) {
|
||||||
// Issue #83: short-circuit if a programmatic caller reaches this for a
|
// MCP virtual skills support enable/disable — the backend forwards it to
|
||||||
// virtual skill (the UI hides the toggle, but defense-in-depth keeps the
|
// the underlying MCP server. ACP virtual skills stay read-only: the UI
|
||||||
// toast accurate when the backend would otherwise return err.skill.not_found
|
// hides their toggle, and this short-circuit keeps the toast accurate if
|
||||||
// on builds that pre-date the rejectVirtualSkillMutation guard).
|
// a programmatic caller still reaches here.
|
||||||
if (isSkillRowVirtual(skill)) {
|
if (isSkillRowVirtual(skill) && !isMcpSkillRow(skill)) {
|
||||||
mcToast.warning(t('skills.virtualReadonlyHint'))
|
mcToast.warning(t('skills.virtualReadonlyHint'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user