mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-15 11:58:34 +08:00
feat(skill): MCP-Skill bridge + system tool dedup + Lessons i18n
This commit is contained in:
parent
18c5c00797
commit
b759eb8719
@ -191,6 +191,14 @@ public class AgentBindingService {
|
|||||||
for (Long skillId : boundSkillIds) {
|
for (Long skillId : boundSkillIds) {
|
||||||
ResolvedSkill resolved = findResolvedSkillById(skillId);
|
ResolvedSkill resolved = findResolvedSkillById(skillId);
|
||||||
if (resolved == null) continue;
|
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();
|
Set<String> skillTools = resolved.getEffectiveAllowedTools();
|
||||||
if (skillTools != null && !skillTools.isEmpty()) merged.addAll(skillTools);
|
if (skillTools != null && !skillTools.isEmpty()) merged.addAll(skillTools);
|
||||||
}
|
}
|
||||||
@ -201,9 +209,46 @@ public class AgentBindingService {
|
|||||||
merged.addAll(directTools);
|
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;
|
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) {
|
private ResolvedSkill findResolvedSkillById(Long skillId) {
|
||||||
if (skillId == null || skillRuntimeService == null) return null;
|
if (skillId == null || skillRuntimeService == null) return null;
|
||||||
// resolveAllSkillsStatus returns every skill in the catalog, not
|
// resolveAllSkillsStatus returns every skill in the catalog, not
|
||||||
|
|||||||
@ -50,6 +50,7 @@ public class SkillController {
|
|||||||
private final AgentSkillBindingMapper agentSkillBindingMapper;
|
private final AgentSkillBindingMapper agentSkillBindingMapper;
|
||||||
private final AgentService agentService;
|
private final AgentService agentService;
|
||||||
private final AgentBindingService agentBindingService;
|
private final AgentBindingService agentBindingService;
|
||||||
|
private final vip.mate.skill.mcp.McpSkillBridge mcpSkillBridge;
|
||||||
|
|
||||||
@Operation(summary = "获取技能分页列表(RFC-042 §2.1)")
|
@Operation(summary = "获取技能分页列表(RFC-042 §2.1)")
|
||||||
@GetMapping
|
@GetMapping
|
||||||
@ -60,7 +61,39 @@ public class SkillController {
|
|||||||
@RequestParam(required = false) String skillType,
|
@RequestParam(required = false) String skillType,
|
||||||
@RequestParam(required = false) Boolean enabled,
|
@RequestParam(required = false) Boolean enabled,
|
||||||
@RequestParam(required = false) String scanStatus) {
|
@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 徽章用)")
|
@Operation(summary = "获取各类型技能计数(tab 徽章用)")
|
||||||
@ -96,6 +129,16 @@ public class SkillController {
|
|||||||
@Operation(summary = "获取技能详情")
|
@Operation(summary = "获取技能详情")
|
||||||
@GetMapping("/{id}")
|
@GetMapping("/{id}")
|
||||||
public R<SkillEntity> get(@PathVariable Long 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));
|
return R.ok(skillService.getSkill(id));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -8,6 +8,7 @@ import org.springframework.context.annotation.Lazy;
|
|||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import vip.mate.skill.lessons.SkillLessonsService;
|
import vip.mate.skill.lessons.SkillLessonsService;
|
||||||
import vip.mate.skill.manifest.SkillManifest;
|
import vip.mate.skill.manifest.SkillManifest;
|
||||||
|
import vip.mate.skill.mcp.McpSkillBridge;
|
||||||
import vip.mate.skill.model.SkillEntity;
|
import vip.mate.skill.model.SkillEntity;
|
||||||
import vip.mate.skill.runtime.model.ResolvedSkill;
|
import vip.mate.skill.runtime.model.ResolvedSkill;
|
||||||
import vip.mate.skill.service.SkillService;
|
import vip.mate.skill.service.SkillService;
|
||||||
@ -40,14 +41,22 @@ public class SkillRuntimeService {
|
|||||||
* constructor below.
|
* constructor below.
|
||||||
*/
|
*/
|
||||||
private final SkillLessonsService lessonsService;
|
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
|
@Autowired
|
||||||
public SkillRuntimeService(SkillService skillService,
|
public SkillRuntimeService(SkillService skillService,
|
||||||
SkillPackageResolver packageResolver,
|
SkillPackageResolver packageResolver,
|
||||||
@Lazy SkillLessonsService lessonsService) {
|
@Lazy SkillLessonsService lessonsService,
|
||||||
|
@Lazy McpSkillBridge mcpSkillBridge) {
|
||||||
this.skillService = skillService;
|
this.skillService = skillService;
|
||||||
this.packageResolver = packageResolver;
|
this.packageResolver = packageResolver;
|
||||||
this.lessonsService = lessonsService;
|
this.lessonsService = lessonsService;
|
||||||
|
this.mcpSkillBridge = mcpSkillBridge;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 缓存已解析的 active skills(5分钟过期)
|
// 缓存已解析的 active skills(5分钟过期)
|
||||||
@ -125,6 +134,16 @@ public class SkillRuntimeService {
|
|||||||
.map(packageResolver::resolve)
|
.map(packageResolver::resolve)
|
||||||
.filter(SkillRuntimeService::passesActiveGate)
|
.filter(SkillRuntimeService::passesActiveGate)
|
||||||
.collect(Collectors.toList());
|
.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);
|
activeSkillsCache.put(CACHE_KEY, resolved);
|
||||||
log.info("Refreshed active skills: {} enabled", resolved.size());
|
log.info("Refreshed active skills: {} enabled", resolved.size());
|
||||||
@ -134,12 +153,23 @@ public class SkillRuntimeService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 解析所有技能的运行时状态(管理页面使用,包含 disabled 和 error 信息)
|
* 解析所有技能的运行时状态(管理页面使用,包含 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() {
|
public List<ResolvedSkill> resolveAllSkillsStatus() {
|
||||||
List<SkillEntity> allSkills = skillService.listSkills();
|
List<SkillEntity> allSkills = skillService.listSkills();
|
||||||
return allSkills.stream()
|
List<ResolvedSkill> resolved = allSkills.stream()
|
||||||
.map(packageResolver::resolve)
|
.map(packageResolver::resolve)
|
||||||
.collect(Collectors.toList());
|
.collect(Collectors.toList());
|
||||||
|
try {
|
||||||
|
resolved.addAll(mcpSkillBridge.listMcpDerivedResolvedSkills());
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("MCP skill bridge merge failed: {}", e.getMessage());
|
||||||
|
}
|
||||||
|
return resolved;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -2047,7 +2047,7 @@ export default {
|
|||||||
local: '本地',
|
local: '本地',
|
||||||
},
|
},
|
||||||
usedByTitle: '已绑定此技能的 Agent 数量',
|
usedByTitle: '已绑定此技能的 Agent 数量',
|
||||||
lessonsCountTitle: '已记录的 lessons — 点击查看',
|
lessonsCountTitle: '已记录的经验数 — 点击查看',
|
||||||
actions: {
|
actions: {
|
||||||
configure: '配置',
|
configure: '配置',
|
||||||
view: '查看',
|
view: '查看',
|
||||||
@ -2075,21 +2075,21 @@ export default {
|
|||||||
manifest: 'Manifest',
|
manifest: 'Manifest',
|
||||||
tools: '工具',
|
tools: '工具',
|
||||||
features: '特性',
|
features: '特性',
|
||||||
lessons: 'Lessons',
|
lessons: '经验',
|
||||||
memory: '记忆',
|
memory: '记忆',
|
||||||
noManifest: '该技能未声明 v3 manifest,使用旧字段。',
|
noManifest: '该技能未声明 v3 manifest,使用旧字段。',
|
||||||
noTools: '该 skill 未声明 allowed-tools。绑定到 agent 时 LLM 将回退到全局工具集。',
|
noTools: '该 skill 未声明 allowed-tools。绑定到 agent 时 LLM 将回退到全局工具集。',
|
||||||
noFeatures: '未声明 features[] 矩阵,技能被视为单一默认特性。',
|
noFeatures: '未声明 features[] 矩阵,技能被视为单一默认特性。',
|
||||||
noLessons: '尚未记录任何 lesson。Lessons 由 LLM 通过 record_lesson 工具写入 — 在对话中告诉 agent「请记录这条经验到 {skill 名}」即可触发。',
|
noLessons: '尚未记录任何经验。经验由 LLM 通过 record_lesson 工具写入 — 在对话中告诉 agent「请把这条经验记录到「{skill 名}」技能下」即可触发。',
|
||||||
noEmployees: '该 skill 当前没有任何 Agent 可以使用。要么在 Agent 的 Skills tab 中显式绑定,要么把 skill 设为全局启用让无显式绑定的 Agent 自动可见。',
|
noEmployees: '该 skill 当前没有任何 Agent 可以使用。要么在 Agent 的 Skills tab 中显式绑定,要么把 skill 设为全局启用让无显式绑定的 Agent 自动可见。',
|
||||||
toolsHint: 'Skill 绑定到 agent 时,这些工具名将合并进 LLM 的 allowed-tools。SETUP_NEEDED 特性下的工具保持隐藏。',
|
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。',
|
memoryHint: '可使用此 skill 的 Agents。显式绑定 = 在 Agent Skills tab 中明确选中;隐式可见 = 该 Agent 未做任何 Skill 显式绑定,自动可见所有全局启用的 skill。',
|
||||||
openMemory: '查看 Agent 记忆',
|
openMemory: '查看 Agent 记忆',
|
||||||
bindingExplicit: '显式绑定',
|
bindingExplicit: '显式绑定',
|
||||||
bindingImplicit: '隐式可见',
|
bindingImplicit: '隐式可见',
|
||||||
clearLessons: '全部清空',
|
clearLessons: '全部清空',
|
||||||
clearLessonsConfirm: '删除该 skill 的全部 lessons?操作不可撤销。',
|
clearLessonsConfirm: '删除该 skill 的全部经验?操作不可撤销。',
|
||||||
},
|
},
|
||||||
runtime: {
|
runtime: {
|
||||||
disabled: '已停用',
|
disabled: '已停用',
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user