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 index 811af15c..4f120141 100644 --- a/mateclaw-server/src/main/java/vip/mate/skill/mcp/McpSkillBridge.java +++ b/mateclaw-server/src/main/java/vip/mate/skill/mcp/McpSkillBridge.java @@ -10,6 +10,7 @@ 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; @@ -196,23 +197,29 @@ public class McpSkillBridge { } /** - * 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. + * Auto-generate the minimal manifest from the MCP server's most-recent + * tool snapshot. The tool list is sourced in priority order: + *
    + *
  1. {@code mate_mcp_server.tools_cache_json} — present whenever the + * server has connected at least once. Lets the picker stay + * populated through brief disconnects.
  2. + *
  3. The runtime in-memory cache (current connection's + * {@code listTools()} result).
  4. + *
+ * + *

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 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()); + List rawNames = readToolRawNames(server); + List toolNames = new ArrayList<>(rawNames.size()); + for (String raw : rawNames) { + toolNames.add(McpToolNameResolver.prefixedName(server.getId(), raw)); } SkillManifest.FeatureDef defaultFeature = SkillManifest.FeatureDef.builder() @@ -253,6 +260,58 @@ public class McpSkillBridge { .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 readToolRawNames(McpServerEntity server) { + List fromCache = parseCachedToolNames(server.getToolsCacheJson()); + if (!fromCache.isEmpty()) { + return fromCache; + } + try { + List discovered = mcpClientManager.getServerTools(server.getId()); + List 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 parseCachedToolNames(String json) { + if (json == null || json.isBlank()) { + return List.of(); + } + try { + cn.hutool.json.JSONArray arr = cn.hutool.json.JSONUtil.parseArray(json); + List 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_-]", "-"); diff --git a/mateclaw-server/src/main/java/vip/mate/tool/mcp/model/McpServerEntity.java b/mateclaw-server/src/main/java/vip/mate/tool/mcp/model/McpServerEntity.java index 1e0bd228..2c5fbaf8 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/mcp/model/McpServerEntity.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/mcp/model/McpServerEntity.java @@ -68,6 +68,20 @@ public class McpServerEntity { /** 远端暴露的工具数量 */ private Integer toolCount; + /** + * Last successful {@code listTools()} response, serialized as a JSON + * array of {@code {name, description, inputSchema}} entries. Refreshed + * by {@code McpServerService} after every successful (re)connect; never + * cleared on failure so the picker keeps working while the upstream + * server is briefly unavailable. Reverse-lookup of a prefixed callback + * name to its raw tool name reads from this column. + */ + @TableField(value = "tools_cache_json", updateStrategy = FieldStrategy.ALWAYS) + private String toolsCacheJson; + + /** Wall-clock timestamp of the last successful tools-cache write. */ + private LocalDateTime toolsCacheUpdatedAt; + /** 是否系统内置 */ private Boolean builtin; diff --git a/mateclaw-server/src/main/java/vip/mate/tool/mcp/runtime/McpClientManager.java b/mateclaw-server/src/main/java/vip/mate/tool/mcp/runtime/McpClientManager.java index 542b1cd5..e55245aa 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/mcp/runtime/McpClientManager.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/mcp/runtime/McpClientManager.java @@ -149,24 +149,79 @@ public class McpClientManager { } /** - * 获取所有 active clients 的 ToolCallback 列表 + * Collect ToolCallbacks from every active MCP client, with each callback's + * name rewritten to a server-id-anchored prefix + * (see {@link McpToolNameResolver}). Two guarantees: + *

*/ public List getAllToolCallbacks() { List allCallbacks = new ArrayList<>(); for (Map.Entry entry : clients.entrySet()) { + long serverId = entry.getKey(); try { SyncMcpToolCallbackProvider provider = new SyncMcpToolCallbackProvider(entry.getValue()); ToolCallback[] cbs = provider.getToolCallbacks(); - if (cbs != null) { - Collections.addAll(allCallbacks, cbs); + if (cbs == null || cbs.length == 0) { + continue; } + allCallbacks.addAll(wrapServerCallbacks(serverId, cbs)); } catch (Exception e) { - log.warn("Failed to get tool callbacks from MCP server {}: {}", entry.getKey(), e.getMessage()); + log.warn("Failed to get tool callbacks from MCP server {}: {}", serverId, e.getMessage()); } } return allCallbacks; } + /** + * Apply per-server collision detection and wrap each surviving callback + * with its prefixed name. Walks {@code cbs} and the matching decision + * list in lockstep so that duplicate raw names are honored + * one-decision-per-callback — a {@code Map} would make + * every duplicate look up the first (bindable) decision and silently + * register two callbacks under the same prefixed name, breaking the + * "runtime and picker share one decision" contract. + * + *

Package-private so unit tests can drive it without standing up a + * real {@link McpSyncClient}. + */ + static List wrapServerCallbacks(long serverId, ToolCallback[] cbs) { + List rawNames = new ArrayList<>(cbs.length); + for (ToolCallback cb : cbs) { + rawNames.add(cb.getToolDefinition() != null ? cb.getToolDefinition().name() : null); + } + List decisions = + McpHashCollisionDetector.classify(serverId, rawNames); + + // classify() drops blank/null raws; advance the decision pointer + // only when the cb's raw is non-blank so the indices stay aligned. + List out = new ArrayList<>(cbs.length); + int dIdx = 0; + for (ToolCallback cb : cbs) { + String raw = cb.getToolDefinition() != null ? cb.getToolDefinition().name() : null; + if (raw == null || raw.isBlank()) { + continue; + } + if (dIdx >= decisions.size()) { + break; + } + McpHashCollisionDetector.Decision d = decisions.get(dIdx++); + if (!d.bindable()) { + log.error("Skipping MCP tool callback on server {} (raw='{}', prefixed='{}'): {}", + serverId, raw, d.prefixedName(), d.unavailableReason()); + continue; + } + out.add(new PrefixedNameToolCallback(d.prefixedName(), cb)); + } + return out; + } + /** * 获取连接结果 */ diff --git a/mateclaw-server/src/main/java/vip/mate/tool/mcp/runtime/McpHashCollisionDetector.java b/mateclaw-server/src/main/java/vip/mate/tool/mcp/runtime/McpHashCollisionDetector.java new file mode 100644 index 00000000..00756ea9 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/tool/mcp/runtime/McpHashCollisionDetector.java @@ -0,0 +1,90 @@ +package vip.mate.tool.mcp.runtime; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Per-server hash collision detector for MCP tool names. + * + *

{@link McpToolNameResolver}'s 30-bit hash makes name collisions + * statistically rare but not impossible. The detector runs the same + * input set through the resolver and reports which raw names collide on + * the same prefixed name, so two callers can agree on which entries are + * "bindable" and which are not: + * + *

    + *
  • {@code McpClientManager} consults the detector before registering + * runtime callbacks, skipping the second of any colliding pair so + * {@link org.springframework.ai.tool.ToolCallback} names stay unique + * in the runtime tool set.
  • + *
  • {@code AvailableToolService} consults the detector when emitting + * picker DTOs, marking colliding entries {@code available=false} + * with reason {@code HASH_COLLISION} so the UI disables them.
  • + *
+ * + *

Sharing the detector keeps these two views in lockstep — without it, + * the picker could offer a tool whose runtime callback was silently + * skipped, letting the user save a binding that resolves to nothing at + * chat time. + * + *

Stateless and thread-safe. + */ +public final class McpHashCollisionDetector { + + private McpHashCollisionDetector() {} + + /** + * Decide which raw tool names are bindable for a given server. + * + *

The first occurrence of each prefixed name wins; later raw names + * that hash to the same prefix are recorded as collided. Iteration + * order of {@code rawToolNames} therefore determines which raw name + * is treated as canonical — callers should pass a stable order + * (typically the order returned by {@code listTools()}). + * + * @return one entry per non-blank input raw name, in input order + */ + public static List classify(long serverId, Collection rawToolNames) { + if (rawToolNames == null || rawToolNames.isEmpty()) { + return List.of(); + } + Map firstRawByPrefixed = new LinkedHashMap<>(); + List out = new ArrayList<>(rawToolNames.size()); + for (String raw : rawToolNames) { + if (raw == null || raw.isBlank()) { + // Defensive: an MCP server shouldn't surface a blank tool name, + // but if it does, drop it instead of letting resolver throw. + continue; + } + String prefixed = McpToolNameResolver.prefixedName(serverId, raw); + String prior = firstRawByPrefixed.putIfAbsent(prefixed, raw); + if (prior == null) { + out.add(new Decision(raw, prefixed, true, null)); + } else if (prior.equals(raw)) { + // Same raw name appearing twice in the input — duplicate + // declaration upstream, not a collision. Keep the first. + out.add(new Decision(raw, prefixed, false, "DUPLICATE_RAW_NAME")); + } else { + out.add(new Decision(raw, prefixed, false, "HASH_COLLISION:" + prior)); + } + } + return out; + } + + /** + * One decision per raw tool name. + * + * @param rawToolName name as discovered from the MCP server + * @param prefixedName resolved {@code mcp___} + * @param bindable {@code true} → runtime should register this + * callback and the picker should offer it as + * {@code available=true}; {@code false} → both + * must skip / disable it + * @param unavailableReason machine-readable cause when {@code !bindable} + */ + public record Decision(String rawToolName, String prefixedName, + boolean bindable, String unavailableReason) {} +} diff --git a/mateclaw-server/src/main/java/vip/mate/tool/mcp/runtime/McpToolNameResolver.java b/mateclaw-server/src/main/java/vip/mate/tool/mcp/runtime/McpToolNameResolver.java new file mode 100644 index 00000000..5464d1be --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/tool/mcp/runtime/McpToolNameResolver.java @@ -0,0 +1,140 @@ +package vip.mate.tool.mcp.runtime; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Locale; +import java.util.regex.Pattern; + +/** + * Single source of truth for MCP tool callback names. + * + *

Format: {@code mcp___} where: + *

    + *
  • {@code } — immutable {@code mate_mcp_server.id} (numeric + * Snowflake). Anchoring to the DB primary key (not the user-visible + * display name) makes display-name renames transparent to bindings.
  • + *
  • {@code } — first 20 chars of {@code [^a-z0-9_-]→'_'} on the + * lowercased raw tool name. Kept for human readability in logs / SQL.
  • + *
  • {@code } — first 6 chars of base32-no-pad + * {@code SHA-256(raw_tool_name)}. Greatly reduces the chance of + * distinct raw names colliding under the same slug; residual collisions + * (probabilistic, not zero) are handled explicitly by + * {@link McpHashCollisionDetector} at registration and picker emission + * time — never relied on as a uniqueness guarantee.
  • + *
+ * + *

Length budget: {@code mcp_} (4) + serverId (≤19) + sep + slug (≤20) + + * sep + hash6 (6) = ≤51 chars, comfortably under any 64-char tool-name caps + * downstream tool engines may enforce. + * + *

The format is not a 1:1 string-only inverse of the raw name. + * The slug stage is lossy (multiple raw names can map to the same slug; + * non-ASCII names map to {@code "tool"}). The hash makes the full key + * statistically unique within {@code (serverId, raw_tool_name)} space, but + * recovering the raw name from the prefixed name alone is not possible. + * Reversal must go through the per-server cached tools list: given + * {@code (serverId, hash6)}, find the cached tool whose + * {@code SHA-256(raw)} hashes to the same prefix. + */ +public final class McpToolNameResolver { + + public static final String PREFIX = "mcp_"; + public static final int SLUG_MAX = 20; + public static final int HASH_LEN = 6; + + private static final Pattern UNSAFE = Pattern.compile("[^a-z0-9_-]"); + // RFC 4648 base32 lowercase, no padding. Lowercase keeps the prefixed + // name fully lowercase + digits + dashes — friendly to URL paths, + // filenames, log greps, and case-insensitive systems. + private static final char[] BASE32 = "abcdefghijklmnopqrstuvwxyz234567".toCharArray(); + + private McpToolNameResolver() {} + + /** Build the prefixed callback name for a given (serverId, raw tool name) pair. */ + public static String prefixedName(long serverId, String rawToolName) { + if (rawToolName == null || rawToolName.isBlank()) { + throw new IllegalArgumentException("rawToolName must not be blank"); + } + return PREFIX + serverId + "_" + slug(rawToolName) + "_" + hash6(rawToolName); + } + + /** + * Parse a prefixed name into its components. Returns {@code null} if the + * input does not match the MCP prefix shape — callers use this to route + * lookups between bridged MCP names and other namespaces. + * + *

Note that {@link ParsedRef} intentionally does not include the raw + * tool name: that requires a cache lookup (see class Javadoc). + */ + public static ParsedRef parse(String prefixedName) { + if (prefixedName == null || !prefixedName.startsWith(PREFIX)) { + return null; + } + int firstSep = prefixedName.indexOf('_', PREFIX.length()); + int lastSep = prefixedName.lastIndexOf('_'); + if (firstSep < 0 || lastSep <= firstSep) { + return null; + } + String serverIdStr = prefixedName.substring(PREFIX.length(), firstSep); + String slug = prefixedName.substring(firstSep + 1, lastSep); + String hash = prefixedName.substring(lastSep + 1); + if (hash.length() != HASH_LEN || slug.isEmpty()) { + return null; + } + long serverId; + try { + serverId = Long.parseLong(serverIdStr); + } catch (NumberFormatException e) { + return null; + } + return new ParsedRef(serverId, slug, hash); + } + + /** Cheap O(prefix length) check used by routing code paths. */ + public static boolean isMcpPrefixedName(String name) { + return name != null && name.startsWith(PREFIX); + } + + /** Reproduce the hash6 of a known raw name — used for cache reverse lookup. */ + public static String hash6(String rawToolName) { + if (rawToolName == null) { + throw new IllegalArgumentException("rawToolName must not be null"); + } + try { + byte[] digest = MessageDigest.getInstance("SHA-256") + .digest(rawToolName.getBytes(StandardCharsets.UTF_8)); + StringBuilder sb = new StringBuilder(HASH_LEN); + for (int i = 0; sb.length() < HASH_LEN; i++) { + sb.append(BASE32[digest[i] & 0x1F]); + } + return sb.toString(); + } catch (NoSuchAlgorithmException e) { + // SHA-256 is mandated by every standard Java runtime — reaching + // this branch means the JVM is misconfigured and the application + // has bigger problems than tool naming. + throw new IllegalStateException("SHA-256 unavailable", e); + } + } + + private static String slug(String raw) { + String s = UNSAFE.matcher(raw.toLowerCase(Locale.ROOT)).replaceAll("_"); + if (s.length() > SLUG_MAX) { + s = s.substring(0, SLUG_MAX); + } + // A raw name composed entirely of non-ASCII chars (e.g. pure CJK) + // collapses to underscores and then to an empty slug after trimming; + // give it a stable placeholder so the prefixed name is still + // well-formed and the hash carries the actual identity. + if (s.replace("_", "").isEmpty()) { + return "tool"; + } + return s; + } + + /** + * Decoded prefix components. {@code rawToolName} is intentionally absent + * — recover it via the per-server tools cache when needed. + */ + public record ParsedRef(long serverId, String slug, String hash6) {} +} diff --git a/mateclaw-server/src/main/java/vip/mate/tool/mcp/runtime/PrefixedNameToolCallback.java b/mateclaw-server/src/main/java/vip/mate/tool/mcp/runtime/PrefixedNameToolCallback.java new file mode 100644 index 00000000..f30e6cf5 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/tool/mcp/runtime/PrefixedNameToolCallback.java @@ -0,0 +1,70 @@ +package vip.mate.tool.mcp.runtime; + +import org.springframework.ai.chat.model.ToolContext; +import org.springframework.ai.tool.ToolCallback; +import org.springframework.ai.tool.definition.DefaultToolDefinition; +import org.springframework.ai.tool.definition.ToolDefinition; +import org.springframework.ai.tool.metadata.ToolMetadata; + +/** + * Wraps a {@link ToolCallback} from an MCP server and overrides + * {@link ToolDefinition#name()} with a stable + * {@code mcp___} key. + * + *

Why wrap rather than configure the upstream provider's prefix + * generator: the upstream extension point only sees protocol-level + * connection metadata, not the database server id we want to anchor + * to. Keeping the prefix logic inside this package binds the contract + * to one place and survives upstream API changes. + * + *

Description, input schema, metadata, and {@code call(...)} are + * forwarded verbatim — the wrapper changes only the name, so guard, + * approval, observability, and return-direct routing all see the same + * string they will write to bindings. + */ +public final class PrefixedNameToolCallback implements ToolCallback { + + private final ToolCallback delegate; + private final ToolDefinition prefixedDefinition; + + public PrefixedNameToolCallback(String prefixedName, ToolCallback delegate) { + if (prefixedName == null || prefixedName.isBlank()) { + throw new IllegalArgumentException("prefixedName must not be blank"); + } + if (delegate == null) { + throw new IllegalArgumentException("delegate must not be null"); + } + this.delegate = delegate; + ToolDefinition original = delegate.getToolDefinition(); + this.prefixedDefinition = DefaultToolDefinition.builder() + .name(prefixedName) + .description(original != null ? original.description() : "") + .inputSchema(original != null ? original.inputSchema() : "{}") + .build(); + } + + @Override + public ToolDefinition getToolDefinition() { + return prefixedDefinition; + } + + @Override + public ToolMetadata getToolMetadata() { + return delegate.getToolMetadata(); + } + + @Override + public String call(String toolInput) { + return delegate.call(toolInput); + } + + @Override + public String call(String toolInput, ToolContext toolContext) { + return delegate.call(toolInput, toolContext); + } + + /** Exposed for diagnostic / wrapping detection (e.g. by ReturnDirect logic). */ + public ToolCallback getDelegate() { + return delegate; + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/tool/mcp/service/McpServerService.java b/mateclaw-server/src/main/java/vip/mate/tool/mcp/service/McpServerService.java index 294ace0f..30b90d86 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/mcp/service/McpServerService.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/mcp/service/McpServerService.java @@ -3,6 +3,7 @@ package vip.mate.tool.mcp.service; import cn.hutool.json.JSONUtil; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import io.modelcontextprotocol.spec.McpSchema; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; @@ -13,6 +14,7 @@ import vip.mate.tool.mcp.runtime.McpClientManager; import vip.mate.tool.mcp.runtime.McpClientManager.ConnectionResult; import java.time.LocalDateTime; +import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.regex.Pattern; @@ -198,7 +200,7 @@ public class McpServerService { try { ConnectionResult result = mcpClientManager.connect(server); if (result.success()) { - updateStatus(server.getId(), "connected", null, result.toolCount()); + onConnectSuccess(server.getId()); } else { updateStatus(server.getId(), "error", result.message(), 0); } @@ -226,7 +228,7 @@ public class McpServerService { try { ConnectionResult result = mcpClientManager.connect(server); if (result.success()) { - updateStatus(server.getId(), "connected", null, result.toolCount()); + onConnectSuccess(server.getId()); } else { updateStatus(server.getId(), "error", result.message(), 0); } @@ -284,7 +286,7 @@ public class McpServerService { try { ConnectionResult result = mcpClientManager.connect(server); if (result.success()) { - updateStatus(server.getId(), "connected", null, result.toolCount()); + onConnectSuccess(server.getId()); } else { mcpClientManager.remove(server.getId()); updateStatus(server.getId(), "error", result.message(), 0); @@ -300,7 +302,7 @@ public class McpServerService { try { ConnectionResult result = mcpClientManager.replace(server); if (result.success()) { - updateStatus(server.getId(), "connected", null, result.toolCount()); + onConnectSuccess(server.getId()); } else { mcpClientManager.remove(server.getId()); updateStatus(server.getId(), "error", result.message(), 0); @@ -312,7 +314,29 @@ public class McpServerService { } } + /** + * Common success path for every connect entry point: snapshot the + * just-discovered tools into the {@code tools_cache_json} column in + * the same DB roundtrip as the status update, so downstream code that + * reads from the entity sees both pieces consistently. + * + *

Cache is only ever overwritten on success — failures preserve the + * last successful snapshot, keeping the agent picker rendering + * something useful while the upstream server is briefly down. + */ + private void onConnectSuccess(Long serverId) { + List tools = mcpClientManager.getServerTools(serverId); + String cacheJson = serializeToolsCache(tools); + updateStatusWithCache(serverId, "connected", null, tools.size(), cacheJson); + } + private void updateStatus(Long id, String status, String error, int toolCount) { + // Failure paths do NOT touch the tools cache — keep the last + // successful snapshot so the picker stays populated. + updateStatusWithCache(id, status, error, toolCount, null); + } + + private void updateStatusWithCache(Long id, String status, String error, int toolCount, String cacheJson) { try { LambdaUpdateWrapper wrapper = new LambdaUpdateWrapper<>(); wrapper.eq(McpServerEntity::getId, id); @@ -322,6 +346,10 @@ public class McpServerService { if ("connected".equals(status)) { wrapper.set(McpServerEntity::getLastConnectedTime, LocalDateTime.now()); } + if (cacheJson != null) { + wrapper.set(McpServerEntity::getToolsCacheJson, cacheJson); + wrapper.set(McpServerEntity::getToolsCacheUpdatedAt, LocalDateTime.now()); + } wrapper.set(McpServerEntity::getUpdateTime, LocalDateTime.now()); mcpServerMapper.update(null, wrapper); } catch (Exception e) { @@ -329,6 +357,37 @@ public class McpServerService { } } + /** + * Serialize the list returned by the upstream {@code listTools()} call + * into a stable JSON shape: an array of {@code {name, description, + * inputSchema}} entries. Schema is stored as the JSON text the upstream + * surfaces (already a JSON-Schema object) so the picker can show it + * verbatim without re-stringifying. + */ + private String serializeToolsCache(List tools) { + if (tools == null || tools.isEmpty()) { + return "[]"; + } + List> rows = new ArrayList<>(tools.size()); + for (McpSchema.Tool t : tools) { + if (t == null || t.name() == null || t.name().isBlank()) continue; + Map row = new java.util.LinkedHashMap<>(); + row.put("name", t.name()); + row.put("description", t.description() != null ? t.description() : ""); + // inputSchema in the MCP record is a JsonSchema record; let the + // JSON utility serialize it, falling back to "{}" if it can't. + try { + row.put("inputSchema", t.inputSchema() != null + ? JSONUtil.parse(JSONUtil.toJsonStr(t.inputSchema())) + : "{}"); + } catch (Exception e) { + row.put("inputSchema", "{}"); + } + rows.add(row); + } + return JSONUtil.toJsonStr(rows); + } + private void validateServer(McpServerEntity entity) { if (entity.getName() == null || entity.getName().isBlank()) { throw new MateClawException("err.mcp.name_required", "MCP server 名称不能为空"); diff --git a/mateclaw-server/src/main/resources/db/migration/h2/V92__mcp_server_tools_cache.sql b/mateclaw-server/src/main/resources/db/migration/h2/V92__mcp_server_tools_cache.sql new file mode 100644 index 00000000..36bdc75f --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/h2/V92__mcp_server_tools_cache.sql @@ -0,0 +1,10 @@ +-- V92: Persist each MCP server's discovered tool list as a per-row JSON +-- snapshot so the agent edit picker can render the tools even when the +-- upstream server is briefly disconnected, and so the per-tool atomic +-- binding flow has a stable place to resolve raw tool names from the +-- prefixed callback name. +-- +-- Idempotent on re-runs (Flyway's repair-on-startup applies). + +ALTER TABLE mate_mcp_server ADD COLUMN IF NOT EXISTS tools_cache_json CLOB; +ALTER TABLE mate_mcp_server ADD COLUMN IF NOT EXISTS tools_cache_updated_at TIMESTAMP; diff --git a/mateclaw-server/src/main/resources/db/migration/mysql/V92__mcp_server_tools_cache.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V92__mcp_server_tools_cache.sql new file mode 100644 index 00000000..e151ddfb --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/mysql/V92__mcp_server_tools_cache.sql @@ -0,0 +1,29 @@ +-- V92: Persist each MCP server's discovered tool list as a per-row JSON +-- snapshot so the agent edit picker can render the tools even when the +-- upstream server is briefly disconnected, and so the per-tool atomic +-- binding flow has a stable place to resolve raw tool names from the +-- prefixed callback name. +-- +-- MySQL doesn't support `ADD COLUMN IF NOT EXISTS` natively (5.7 and most +-- 8.0 deployments), so guard each ALTER with an INFORMATION_SCHEMA lookup +-- + PREPARE/EXECUTE so re-runs become no-ops instead of failing the +-- migration. Flyway's repair-on-startup compensates for any partial +-- failure. + +SET @c := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'mate_mcp_server' + AND COLUMN_NAME = 'tools_cache_json'); +SET @s := IF(@c = 0, + 'ALTER TABLE mate_mcp_server ADD COLUMN tools_cache_json MEDIUMTEXT', + 'SELECT 1'); +PREPARE stmt FROM @s; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'mate_mcp_server' + AND COLUMN_NAME = 'tools_cache_updated_at'); +SET @s := IF(@c = 0, + 'ALTER TABLE mate_mcp_server ADD COLUMN tools_cache_updated_at TIMESTAMP NULL', + 'SELECT 1'); +PREPARE stmt FROM @s; EXECUTE stmt; DEALLOCATE PREPARE stmt;