diff --git a/mateclaw-server/src/main/java/vip/mate/agent/binding/service/AgentBindingService.java b/mateclaw-server/src/main/java/vip/mate/agent/binding/service/AgentBindingService.java index b88cfe00..c9498e21 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/binding/service/AgentBindingService.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/binding/service/AgentBindingService.java @@ -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 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. + * + *

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 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 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 0313762f..9d4e420b 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 @@ -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 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 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 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 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 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 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)); } diff --git a/mateclaw-server/src/main/java/vip/mate/skill/mcp/McpSkillBridge.java b/mateclaw-server/src/main/java/vip/mate/skill/mcp/McpSkillBridge.java new file mode 100644 index 00000000..811af15c --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/skill/mcp/McpSkillBridge.java @@ -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. + * + *

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

Why "virtual" not "persisted": + *

+ * + *

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 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 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 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 featureStatuses = new LinkedHashMap<>(); + featureStatuses.put("default", connected ? "READY" : (errored ? "SETUP_NEEDED" : "SETUP_NEEDED")); + java.util.Set active = new LinkedHashSet<>(); + if (connected) active.add("default"); + + List 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 toolNames = new ArrayList<>(); + try { + List 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; + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillRuntimeService.java b/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillRuntimeService.java index 41bd13f3..a7c758c8 100644 --- a/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillRuntimeService.java +++ b/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillRuntimeService.java @@ -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 skills(5分钟过期) @@ -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 信息) + * + *

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 resolveAllSkillsStatus() { List allSkills = skillService.listSkills(); - return allSkills.stream() + List 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; } /** diff --git a/mateclaw-ui/src/i18n/locales/zh-CN.ts b/mateclaw-ui/src/i18n/locales/zh-CN.ts index d8b80fd0..f42955b1 100644 --- a/mateclaw-ui/src/i18n/locales/zh-CN.ts +++ b/mateclaw-ui/src/i18n/locales/zh-CN.ts @@ -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: '已停用',