feat(skill): MCP-Skill bridge + system tool dedup + Lessons i18n

This commit is contained in:
matevip 2026-05-01 09:50:45 +08:00
parent 18c5c00797
commit b759eb8719
5 changed files with 450 additions and 8 deletions

View File

@ -191,6 +191,14 @@ public class AgentBindingService {
for (Long skillId : boundSkillIds) {
ResolvedSkill resolved = findResolvedSkillById(skillId);
if (resolved == null) continue;
if (!vip.mate.skill.runtime.SkillRuntimeService.passesActiveGate(resolved)) {
// §14.2 fix: a disabled / security-blocked / setup-needed
// skill must not contribute tools to the LLM
// advertisement even if it's still bound. Without this
// guard, users see ghost tools for skills they thought
// were off.
continue;
}
Set<String> skillTools = resolved.getEffectiveAllowedTools();
if (skillTools != null && !skillTools.isEmpty()) merged.addAll(skillTools);
}
@ -201,9 +209,46 @@ public class AgentBindingService {
merged.addAll(directTools);
}
// System-level tools that don't belong to any single skill but
// are agent-wide capabilities. Without this carve-out, binding
// any skill silently strips record_lesson / remember / structured-
// memory tools, breaking the §11 self-evolution loop entirely
// (the LLM stops being able to write to LESSONS.md / MEMORY.md).
merged.addAll(SYSTEM_LEVEL_TOOLS);
return merged;
}
/**
* RFC-090 §11 tools that exist outside the skill scope and must
* survive any agent-level skill binding restriction.
*
* <p>Add new entries here only after verifying the tool is genuinely
* agent-wide, not skill-specific. Tools added here bypass the
* {@link #getEffectiveToolNames} allowlist completely.
*/
private static final Set<String> SYSTEM_LEVEL_TOOLS = Set.of(
// Memory write/read primitives every agent needs these
// regardless of skill bindings, otherwise the self-evolution
// path collapses (§11.3 / §11.4).
"record_lesson",
"remember",
"remember_structured",
"recall_structured",
"forget_structured",
// Workspace memory file CRUD (PROFILE.md / MEMORY.md / SOUL.md)
"read_workspace_file",
"write_workspace_file",
"list_workspace_files",
// Skill discovery / dispatch skills are docs, not callables;
// these helpers let the LLM read SKILL.md / run scripts.
"readSkillFile",
"runSkillScript",
// Date/time + delegate fundamental cross-skill utilities
"datetime",
"delegate_agent"
);
private ResolvedSkill findResolvedSkillById(Long skillId) {
if (skillId == null || skillRuntimeService == null) return null;
// resolveAllSkillsStatus returns every skill in the catalog, not

View File

@ -50,6 +50,7 @@ public class SkillController {
private final AgentSkillBindingMapper agentSkillBindingMapper;
private final AgentService agentService;
private final AgentBindingService agentBindingService;
private final vip.mate.skill.mcp.McpSkillBridge mcpSkillBridge;
@Operation(summary = "获取技能分页列表RFC-042 §2.1")
@GetMapping
@ -60,7 +61,39 @@ public class SkillController {
@RequestParam(required = false) String skillType,
@RequestParam(required = false) Boolean enabled,
@RequestParam(required = false) String scanStatus) {
return R.ok(skillService.pageSkills(page, size, keyword, skillType, enabled, scanStatus));
IPage<SkillEntity> dbPage = skillService.pageSkills(page, size, keyword, skillType, enabled, scanStatus);
// RFC-090 §3.2 surface MCP servers as virtual skills on the
// first page so users see GitHub / Filesystem / etc. as cards
// alongside built-in and uploaded skills. Restricted to page 1
// because virtual rows are unpaginated; a follow-up can push
// them through proper SQL UNION ALL when MCP server count gets
// into the dozens.
if (page == 1 && (skillType == null || skillType.isBlank() || "mcp".equalsIgnoreCase(skillType))) {
try {
List<SkillEntity> mcpSkills = mcpSkillBridge.listMcpDerivedSkillEntities();
if (!mcpSkills.isEmpty()) {
// In-memory filter mirrors the DB filters so the
// user's filter chips still apply to virtual rows.
String kw = keyword == null ? "" : keyword.trim().toLowerCase();
List<SkillEntity> filtered = mcpSkills.stream()
.filter(s -> kw.isEmpty()
|| (s.getName() != null && s.getName().toLowerCase().contains(kw))
|| (s.getDescription() != null && s.getDescription().toLowerCase().contains(kw)))
.filter(s -> enabled == null || enabled.equals(s.getEnabled()))
.toList();
if (!filtered.isEmpty()) {
java.util.List<SkillEntity> merged = new java.util.ArrayList<>(filtered);
merged.addAll(dbPage.getRecords());
dbPage.setRecords(merged);
dbPage.setTotal(dbPage.getTotal() + filtered.size());
}
}
} catch (Exception e) {
// Bridge failure must not break the Skills page.
}
}
return R.ok(dbPage);
}
@Operation(summary = "获取各类型技能计数tab 徽章用)")
@ -96,6 +129,16 @@ public class SkillController {
@Operation(summary = "获取技能详情")
@GetMapping("/{id}")
public R<SkillEntity> get(@PathVariable Long id) {
// RFC-090 §3.2 virtual MCP-derived skills synthesize a row
// on demand from the live MCP server entity.
if (vip.mate.skill.mcp.McpSkillBridge.isVirtualMcpSkillId(id)) {
List<SkillEntity> virt = mcpSkillBridge.listMcpDerivedSkillEntities();
return virt.stream()
.filter(s -> id.equals(s.getId()))
.findFirst()
.map(R::ok)
.orElse(R.fail("MCP-derived skill not found: " + id));
}
return R.ok(skillService.getSkill(id));
}

View File

@ -0,0 +1,324 @@
package vip.mate.skill.mcp;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.modelcontextprotocol.spec.McpSchema;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import vip.mate.skill.manifest.SkillManifest;
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.ArrayList;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
/**
* RFC-090 §3.2 / §5.7 / §10.2 Q2 MCP server virtual skill bridge.
*
* <p>MCP servers and skills are the same thing from the user's
* perspective: capability supply for digital employees. The protocol
* ({@code mate_mcp_server}) is implementation detail. This bridge
* makes that consistent: every enabled MCP server becomes a virtual
* {@link SkillEntity} + {@link ResolvedSkill} and shows up on the
* Skills page exactly like a built-in or uploaded skill.
*
* <p>Why "virtual" not "persisted":
* <ul>
* <li>Single source of truth MCP server definition lives in
* {@code mate_mcp_server}, not duplicated to {@code mate_skill}.
* Avoids the double-write drift §14.6 warns about.</li>
* <li>Discovered tools change with the upstream server, not at our
* cadence. A virtual skill rebuilds on every list call so the
* tool count stays fresh.</li>
* <li>Settings MCP Connections remains the only place to edit
* transport / command / args / env. Skills page is read-only
* for MCP entries (View opens drawer; "Configure connection"
* links back to the MCP page).</li>
* </ul>
*
* <p>ID namespace: virtual skill ids use a high sentinel
* {@link #VIRTUAL_ID_BASE} + mcpServerId so they can never collide
* with real {@code mate_skill.id} values (Snowflake longs are bounded
* well below this base). Negative numbers were considered but several
* existing endpoints {@code abs()} the id for path constraints.
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class McpSkillBridge {
/**
* High sentinel for virtual id space. Snowflake ids fit in 63 bits
* but in practice never approach this magnitude, so anything
* {@code >= VIRTUAL_ID_BASE} is unambiguously a bridged MCP skill.
*/
public static final long VIRTUAL_ID_BASE = 9_000_000_000_000_000_000L;
private final McpServerService mcpServerService;
private final McpClientManager mcpClientManager;
private final ObjectMapper objectMapper;
/**
* @return true iff the given id falls inside the virtual MCP skill
* range. Cheap O(1) check, callers use it to route lookups
* between the real DB and this bridge.
*/
public static boolean isVirtualMcpSkillId(Long id) {
return id != null && id >= VIRTUAL_ID_BASE;
}
/** Inverse mapping: extract the original MCP server id. */
public static Long extractMcpServerId(Long virtualId) {
if (!isVirtualMcpSkillId(virtualId)) return null;
return virtualId - VIRTUAL_ID_BASE;
}
public static long virtualIdFor(McpServerEntity server) {
return VIRTUAL_ID_BASE + server.getId();
}
/**
* Snapshot every enabled MCP server as a virtual {@link SkillEntity}.
* Used by the Skills list endpoint; rows are non-persistent and
* regenerated on each call.
*/
public List<SkillEntity> listMcpDerivedSkillEntities() {
return listEnabledServers().stream().map(this::serverToEntity).toList();
}
/**
* Snapshot every enabled MCP server as a virtual {@link ResolvedSkill}
* with synthesized manifest, ready to be merged into the runtime
* status feed. Status reflects connection health: OK READY default
* feature; ERROR / disconnected SETUP_NEEDED with a diagnostic
* missing-dependency entry.
*/
public List<ResolvedSkill> listMcpDerivedResolvedSkills() {
return listEnabledServers().stream().map(this::serverToResolved).toList();
}
/**
* Lookup a single virtual ResolvedSkill by virtual id; null when
* the id is out of range or the server has been removed.
*/
public ResolvedSkill findResolvedById(Long virtualId) {
Long serverId = extractMcpServerId(virtualId);
if (serverId == null) return null;
try {
McpServerEntity server = mcpServerService.getById(serverId);
return server != null ? serverToResolved(server) : null;
} catch (Exception e) {
log.debug("MCP bridge lookup failed for virtual id {}: {}", virtualId, e.getMessage());
return null;
}
}
private List<McpServerEntity> listEnabledServers() {
try {
return mcpServerService.listEnabled();
} catch (Exception e) {
log.warn("MCP bridge could not list enabled servers: {}", e.getMessage());
return List.of();
}
}
private SkillEntity serverToEntity(McpServerEntity server) {
SkillEntity s = new SkillEntity();
s.setId(virtualIdFor(server));
s.setName(slugify(server.getName()));
s.setNameEn(displayName(server));
s.setNameZh(server.getDescription() != null && !server.getDescription().isBlank()
? displayName(server) : null);
s.setDescription(buildDescription(server));
s.setSkillType("mcp");
s.setIcon(iconFor(server));
s.setVersion("1.0.0");
s.setAuthor("mcp-bridge");
s.setEnabled(Boolean.TRUE.equals(server.getEnabled()));
s.setBuiltin(false);
s.setTags("mcp");
s.setSecurityScanStatus("PASSED"); // MCP servers don't go through SkillSecurityService
s.setConfigJson(buildConfigJson(server));
s.setManifestJson(serializeManifest(buildManifest(server)));
return s;
}
private ResolvedSkill serverToResolved(McpServerEntity server) {
SkillManifest manifest = buildManifest(server);
boolean connected = "connected".equalsIgnoreCase(nullSafe(server.getLastStatus()));
boolean errored = "error".equalsIgnoreCase(nullSafe(server.getLastStatus()))
|| (server.getLastError() != null && !server.getLastError().isBlank());
Map<String, String> featureStatuses = new LinkedHashMap<>();
featureStatuses.put("default", connected ? "READY" : (errored ? "SETUP_NEEDED" : "SETUP_NEEDED"));
java.util.Set<String> active = new LinkedHashSet<>();
if (connected) active.add("default");
List<String> missing = new ArrayList<>();
if (!connected) {
missing.add("mcp:" + server.getName() + " (status: "
+ nullSafe(server.getLastStatus()) + ")");
}
return ResolvedSkill.builder()
.id(virtualIdFor(server))
.name(slugify(server.getName()))
.description(buildDescription(server))
.content("") // no SKILL.md
.source("mcp")
.skillDir(null)
.configuredSkillDir(null)
.runtimeAvailable(connected)
.resolutionError(connected ? null : nullSafe(server.getLastError()))
.references(Map.of())
.scripts(Map.of())
.enabled(Boolean.TRUE.equals(server.getEnabled()))
.icon(iconFor(server))
.builtin(false)
.securityBlocked(false)
.securitySummary("MCP-derived skill (bypasses SkillSecurityService)")
.dependencyReady(connected)
.missingDependencies(missing)
.dependencySummary(connected
? "MCP server '" + server.getName() + "' connected"
: "MCP server '" + server.getName() + "' not connected")
.manifest(manifest)
.featureStatuses(featureStatuses)
.activeFeatures(active)
.build();
}
/**
* Auto-generate the §10.2 Q2 minimal manifest from the live MCP
* server. Tool list is the union of discovered MCP tools; one
* synthetic feature {@code default} carries them so the standard
* features-aware gate light up correctly.
*/
private SkillManifest buildManifest(McpServerEntity server) {
List<String> toolNames = new ArrayList<>();
try {
List<McpSchema.Tool> discovered = mcpClientManager.getServerTools(server.getId());
for (McpSchema.Tool t : discovered) {
if (t == null) continue;
String n = t.name();
if (n != null && !n.isBlank()) toolNames.add(n);
}
} catch (Exception e) {
log.debug("MCP bridge manifest build: getServerTools({}) failed: {}",
server.getId(), e.getMessage());
}
SkillManifest.FeatureDef defaultFeature = SkillManifest.FeatureDef.builder()
.id("default")
.label(displayName(server))
.requires(List.of("mcp:" + server.getName()))
.platforms(List.of())
.tools(toolNames)
.build();
SkillManifest.RequirementDef mcpRequirement = SkillManifest.RequirementDef.builder()
.key("mcp:" + server.getName())
.type("mcp")
.check(server.getName())
.description("MCP server '" + server.getName() + "' must be connected. Configure in Settings ▸ MCP Connections.")
.build();
return SkillManifest.builder()
.id(slugify(server.getName()))
.name(slugify(server.getName()))
.description(buildDescription(server))
.icon(iconFor(server))
.version("1.0.0")
.author("mcp-bridge")
.type("mcp")
.category(categoryFor(server))
.allowedTools(toolNames)
.requires(List.of(mcpRequirement))
.features(List.of(defaultFeature))
.selfEvolution(SkillManifest.SelfEvolution.builder()
// MCP-derived skills don't write LESSONS.md the
// upstream protocol layer is the canonical source.
.lessonsEnabled(false)
.lessonsMaxEntries(0)
.memoryWritesAllowed(true)
.build())
.extras(Map.of("mcpServerId", server.getId()))
.build();
}
private String slugify(String raw) {
if (raw == null) return "";
return raw.toLowerCase(Locale.ROOT).replaceAll("[^a-z0-9_-]", "-");
}
private String displayName(McpServerEntity server) {
return server.getName() != null ? server.getName() : "mcp-" + server.getId();
}
private String buildDescription(McpServerEntity server) {
if (server.getDescription() != null && !server.getDescription().isBlank()) {
return server.getDescription();
}
int toolCount = server.getToolCount() == null ? 0 : server.getToolCount();
return "MCP server " + server.getName()
+ (toolCount > 0 ? " · provides " + toolCount + " tools" : "")
+ ". Configure in Settings ▸ MCP Connections.";
}
private String iconFor(McpServerEntity server) {
// Light heuristic pick something recognisable for the most
// popular MCP servers, fall back to the generic plug emoji.
String n = nullSafe(server.getName()).toLowerCase(Locale.ROOT);
if (n.contains("github")) return "🐙";
if (n.contains("gitlab")) return "🦊";
if (n.contains("filesystem") || n.contains("file")) return "📁";
if (n.contains("postgres") || n.contains("mysql") || n.contains("sql") || n.contains("db")) return "🗄️";
if (n.contains("slack")) return "💬";
if (n.contains("notion")) return "📝";
if (n.contains("memory")) return "🧠";
if (n.contains("brave") || n.contains("search")) return "🔍";
if (n.contains("puppeteer") || n.contains("browser")) return "🌐";
return "🔌";
}
private String categoryFor(McpServerEntity server) {
String n = nullSafe(server.getName()).toLowerCase(Locale.ROOT);
if (n.contains("github") || n.contains("gitlab")) return "system";
if (n.contains("file")) return "file";
if (n.contains("sql") || n.contains("postgres") || n.contains("db")) return "data";
if (n.contains("search") || n.contains("brave")) return "web";
if (n.contains("slack") || n.contains("notion")) return "comm";
return "system";
}
private String buildConfigJson(McpServerEntity server) {
try {
return objectMapper.writeValueAsString(Map.of(
"mcpServerId", server.getId(),
"transport", nullSafe(server.getTransport()),
"source", Map.of("type", "mcp")
));
} catch (Exception e) {
return "{}";
}
}
private String serializeManifest(SkillManifest manifest) {
try {
return objectMapper.writeValueAsString(manifest);
} catch (Exception e) {
return null;
}
}
private static String nullSafe(String s) {
return s == null ? "" : s;
}
}

View File

@ -8,6 +8,7 @@ import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Service;
import vip.mate.skill.lessons.SkillLessonsService;
import vip.mate.skill.manifest.SkillManifest;
import vip.mate.skill.mcp.McpSkillBridge;
import vip.mate.skill.model.SkillEntity;
import vip.mate.skill.runtime.model.ResolvedSkill;
import vip.mate.skill.service.SkillService;
@ -40,14 +41,22 @@ public class SkillRuntimeService {
* constructor below.
*/
private final SkillLessonsService lessonsService;
/**
* RFC-090 §3.2 / §10.2 Q2 MCP-server virtual-skill bridge.
* {@code @Lazy} because the bridge depends on McpClientManager
* which boots later in the lifecycle.
*/
private final McpSkillBridge mcpSkillBridge;
@Autowired
public SkillRuntimeService(SkillService skillService,
SkillPackageResolver packageResolver,
@Lazy SkillLessonsService lessonsService) {
@Lazy SkillLessonsService lessonsService,
@Lazy McpSkillBridge mcpSkillBridge) {
this.skillService = skillService;
this.packageResolver = packageResolver;
this.lessonsService = lessonsService;
this.mcpSkillBridge = mcpSkillBridge;
}
// 缓存已解析的 active skills5分钟过期
@ -125,6 +134,16 @@ public class SkillRuntimeService {
.map(packageResolver::resolve)
.filter(SkillRuntimeService::passesActiveGate)
.collect(Collectors.toList());
try {
// RFC-090 §3.2 MCP-derived virtual skills go through the
// same active gate so a disconnected MCP server doesn't
// pollute the prompt enhancement.
for (ResolvedSkill virt : mcpSkillBridge.listMcpDerivedResolvedSkills()) {
if (passesActiveGate(virt)) resolved.add(virt);
}
} catch (Exception e) {
log.warn("MCP skill bridge active merge failed: {}", e.getMessage());
}
activeSkillsCache.put(CACHE_KEY, resolved);
log.info("Refreshed active skills: {} enabled", resolved.size());
@ -134,12 +153,23 @@ public class SkillRuntimeService {
/**
* 解析所有技能的运行时状态管理页面使用包含 disabled error 信息
*
* <p>RFC-090 §3.2 appends virtual MCP-derived skills so the Skills
* page can render MCP servers as first-class skill cards. Real
* skills resolve through the full pipeline; virtual ones come
* pre-built from {@link McpSkillBridge}.
*/
public List<ResolvedSkill> resolveAllSkillsStatus() {
List<SkillEntity> allSkills = skillService.listSkills();
return allSkills.stream()
List<ResolvedSkill> resolved = allSkills.stream()
.map(packageResolver::resolve)
.collect(Collectors.toList());
try {
resolved.addAll(mcpSkillBridge.listMcpDerivedResolvedSkills());
} catch (Exception e) {
log.warn("MCP skill bridge merge failed: {}", e.getMessage());
}
return resolved;
}
/**

View File

@ -2047,7 +2047,7 @@ export default {
local: '本地',
},
usedByTitle: '已绑定此技能的 Agent 数量',
lessonsCountTitle: '已记录的 lessons — 点击查看',
lessonsCountTitle: '已记录的经验数 — 点击查看',
actions: {
configure: '配置',
view: '查看',
@ -2075,21 +2075,21 @@ export default {
manifest: 'Manifest',
tools: '工具',
features: '特性',
lessons: 'Lessons',
lessons: '经验',
memory: '记忆',
noManifest: '该技能未声明 v3 manifest使用旧字段。',
noTools: '该 skill 未声明 allowed-tools。绑定到 agent 时 LLM 将回退到全局工具集。',
noFeatures: '未声明 features[] 矩阵,技能被视为单一默认特性。',
noLessons: '尚未记录任何 lesson。Lessons 由 LLM 通过 record_lesson 工具写入 — 在对话中告诉 agent「请记录这条经验到 {skill 名}」即可触发。',
noLessons: '尚未记录任何经验。经验由 LLM 通过 record_lesson 工具写入 — 在对话中告诉 agent「请把这条经验记录到「{skill 名}」技能下」即可触发。',
noEmployees: '该 skill 当前没有任何 Agent 可以使用。要么在 Agent 的 Skills tab 中显式绑定,要么把 skill 设为全局启用让无显式绑定的 Agent 自动可见。',
toolsHint: 'Skill 绑定到 agent 时,这些工具名将合并进 LLM 的 allowed-tools。SETUP_NEEDED 特性下的工具保持隐藏。',
lessonsHint: '该 skill 的 LESSONS.md 内容。下次加载时自动注入到 SKILL.md 正文之后。',
lessonsHint: '该 skill 累积的经验LESSONS.md。下次加载时自动注入到 SKILL.md 正文之后,让 agent 越用越熟。',
memoryHint: '可使用此 skill 的 Agents。显式绑定 = 在 Agent Skills tab 中明确选中;隐式可见 = 该 Agent 未做任何 Skill 显式绑定,自动可见所有全局启用的 skill。',
openMemory: '查看 Agent 记忆',
bindingExplicit: '显式绑定',
bindingImplicit: '隐式可见',
clearLessons: '全部清空',
clearLessonsConfirm: '删除该 skill 的全部 lessons?操作不可撤销。',
clearLessonsConfirm: '删除该 skill 的全部经验?操作不可撤销。',
},
runtime: {
disabled: '已停用',