mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 11:13:43 +08:00
437 lines
18 KiB
Java
437 lines
18 KiB
Java
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.runtime.McpToolNameResolver;
|
|
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 ids encode a 2-bit type tag in the top two
|
|
* bits of a {@code long}, leaving 62 bits to carry the underlying
|
|
* mcpServerId:
|
|
* <pre>
|
|
* bit 63 (sign) | bit 62 | bits 0..61
|
|
* --------------+--------+--------------------------------
|
|
* 0 | 0 | real persisted skill (Snowflake)
|
|
* 1 | 0 | virtual MCP-derived skill
|
|
* 1 | 1 | virtual ACP-derived skill
|
|
* </pre>
|
|
*
|
|
* <p>The earlier {@code 9e18 + serverId} addition scheme broke once
|
|
* Snowflake-issued mcpServerIds crossed ~{@code 2e18} (the sum then
|
|
* overflowed signed long, wrapping to a negative number that no longer
|
|
* satisfied {@code id >= 9e18} — every detail / lookup of a freshly
|
|
* created MCP server 500'd with "技能不存在"). The bit-tagged layout
|
|
* has no arithmetic and survives any 62-bit server id.
|
|
*
|
|
* <p>The constants are arranged so that {@code BASE + smallId} still
|
|
* equals {@code BASE | smallId} for any {@code smallId < 2^62}, so
|
|
* test fixtures that build virtual ids by addition keep working.
|
|
*/
|
|
@Slf4j
|
|
@Service
|
|
@RequiredArgsConstructor
|
|
public class McpSkillBridge {
|
|
|
|
/**
|
|
* Type tag for the MCP virtual id space: bit 63 set, bit 62 clear.
|
|
* Equal to {@link Long#MIN_VALUE}; named for the historical
|
|
* "base sentinel" idiom callers still use.
|
|
*/
|
|
public static final long VIRTUAL_ID_BASE = Long.MIN_VALUE; // 0x8000000000000000L
|
|
|
|
/** Selects the top-two type-tag bits. */
|
|
private static final long TAG_MASK = 0xC000000000000000L;
|
|
/** Selects the bottom 62 bits that carry the original server id. */
|
|
private static final long ID_MASK = 0x3FFFFFFFFFFFFFFFL;
|
|
|
|
private final McpServerService mcpServerService;
|
|
private final McpClientManager mcpClientManager;
|
|
private final ObjectMapper objectMapper;
|
|
|
|
/**
|
|
* @return true iff the given id carries the MCP virtual-skill type
|
|
* tag (bit 63 set, bit 62 clear). Cheap O(1) bit-mask check.
|
|
*/
|
|
public static boolean isVirtualMcpSkillId(Long id) {
|
|
return id != null && (id & TAG_MASK) == VIRTUAL_ID_BASE;
|
|
}
|
|
|
|
/** Inverse mapping: extract the original MCP server id. */
|
|
public static Long extractMcpServerId(Long virtualId) {
|
|
if (!isVirtualMcpSkillId(virtualId)) return null;
|
|
return virtualId & ID_MASK;
|
|
}
|
|
|
|
public static long virtualIdFor(McpServerEntity server) {
|
|
long sid = server.getId();
|
|
if ((sid & TAG_MASK) != 0L) {
|
|
throw new IllegalStateException(
|
|
"MCP server id 0x" + Long.toHexString(sid)
|
|
+ " uses the top two bits — would collide with the virtual id type tag");
|
|
}
|
|
return VIRTUAL_ID_BASE | sid;
|
|
}
|
|
|
|
/**
|
|
* 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(slugForServer(server));
|
|
s.setNameEn(displayName(server));
|
|
s.setNameZh(displayName(server));
|
|
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(buildManifestFrom(server, readToolRawNames(server))));
|
|
return s;
|
|
}
|
|
|
|
private ResolvedSkill serverToResolved(McpServerEntity server) {
|
|
List<String> rawNames = readToolRawNames(server);
|
|
Map<String, String> toolDisplayNames = new LinkedHashMap<>();
|
|
for (String raw : rawNames) {
|
|
String prefixed = McpToolNameResolver.prefixedName(server.getId(), raw);
|
|
toolDisplayNames.put(prefixed, prefixed + " (" + raw + ")");
|
|
}
|
|
SkillManifest manifest = buildManifestFrom(server, rawNames);
|
|
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(slugForServer(server))
|
|
.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)
|
|
.toolDisplayNames(toolDisplayNames)
|
|
.build();
|
|
}
|
|
|
|
/**
|
|
* Auto-generate the minimal manifest from the MCP server's most-recent
|
|
* tool snapshot. The tool list is sourced in priority order:
|
|
* <ol>
|
|
* <li>{@code mate_mcp_server.tools_cache_json} — present whenever the
|
|
* server has connected at least once. Lets the picker stay
|
|
* populated through brief disconnects.</li>
|
|
* <li>The runtime in-memory cache (current connection's
|
|
* {@code listTools()} result).</li>
|
|
* </ol>
|
|
*
|
|
* <p>Tool names emitted into {@code manifest.allowedTools} go through
|
|
* {@link McpToolNameResolver#prefixedName(long, String)} so they match
|
|
* the runtime callback names registered by
|
|
* {@link McpClientManager#getAllToolCallbacks()}. Without this, a
|
|
* resolved skill's effective allowlist would carry raw names that
|
|
* don't appear in any agent's callbacks at chat time, and the LLM
|
|
* would see no MCP tools even though the bindings were saved.
|
|
*/
|
|
private SkillManifest buildManifestFrom(McpServerEntity server, List<String> rawNames) {
|
|
List<String> toolNames = new ArrayList<>(rawNames.size());
|
|
for (String raw : rawNames) {
|
|
toolNames.add(McpToolNameResolver.prefixedName(server.getId(), raw));
|
|
}
|
|
|
|
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();
|
|
|
|
String slug = slugForServer(server);
|
|
return SkillManifest.builder()
|
|
.id(slug)
|
|
.name(slug)
|
|
.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();
|
|
}
|
|
|
|
/**
|
|
* Resolve the raw tool name list for a server with cache-first / live-fallback
|
|
* semantics. Returns an empty list (never null) so the manifest builder
|
|
* stays simple.
|
|
*/
|
|
private List<String> readToolRawNames(McpServerEntity server) {
|
|
List<String> fromCache = parseCachedToolNames(server.getToolsCacheJson());
|
|
if (!fromCache.isEmpty()) {
|
|
return fromCache;
|
|
}
|
|
try {
|
|
List<McpSchema.Tool> discovered = mcpClientManager.getServerTools(server.getId());
|
|
List<String> names = new ArrayList<>(discovered.size());
|
|
for (McpSchema.Tool t : discovered) {
|
|
if (t == null) continue;
|
|
String n = t.name();
|
|
if (n != null && !n.isBlank()) names.add(n);
|
|
}
|
|
return names;
|
|
} catch (Exception e) {
|
|
log.debug("MCP bridge manifest build: getServerTools({}) failed: {}",
|
|
server.getId(), e.getMessage());
|
|
return List.of();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Parse the {@code tools_cache_json} column written by
|
|
* {@code McpServerService} after each successful connect. Returns an
|
|
* empty list if the column is null/blank/malformed — the bridge is
|
|
* required 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) {
|
|
if (json == null || json.isBlank()) {
|
|
return List.of();
|
|
}
|
|
try {
|
|
cn.hutool.json.JSONArray arr = cn.hutool.json.JSONUtil.parseArray(json);
|
|
List<String> out = new ArrayList<>(arr.size());
|
|
for (Object obj : arr) {
|
|
if (!(obj instanceof cn.hutool.json.JSONObject jo)) continue;
|
|
String name = jo.getStr("name");
|
|
if (name != null && !name.isBlank()) out.add(name);
|
|
}
|
|
return out;
|
|
} catch (Exception e) {
|
|
log.debug("MCP bridge: failed to parse tools_cache_json: {}", e.getMessage());
|
|
return List.of();
|
|
}
|
|
}
|
|
|
|
private String slugify(String raw) {
|
|
if (raw == null) return "";
|
|
return raw.toLowerCase(Locale.ROOT).replaceAll("[^a-z0-9_-]", "-");
|
|
}
|
|
|
|
/**
|
|
* Stable slug for an MCP server. Falls back to {@code mcp-{id}} when
|
|
* the source name has no ASCII letter/digit (e.g. pure CJK), because
|
|
* the naive slugify would otherwise return a run of dashes — making
|
|
* two differently-named all-CJK servers collide on the same display
|
|
* key and breaking name-based skill lookup.
|
|
*/
|
|
private String slugForServer(McpServerEntity server) {
|
|
String slug = slugify(server.getName());
|
|
return hasAsciiAlphaNumeric(slug) ? slug : "mcp-" + server.getId();
|
|
}
|
|
|
|
private static boolean hasAsciiAlphaNumeric(String s) {
|
|
if (s == null || s.isEmpty()) return false;
|
|
for (int i = 0; i < s.length(); i++) {
|
|
char c = s.charAt(i);
|
|
if ((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9')) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|