feat(mcp): stable prefixed callback names and persisted per-server tool cache

This commit is contained in:
matevip 2026-05-07 08:17:30 +08:00
parent f5d25509bb
commit 845b5bb1b1
9 changed files with 549 additions and 23 deletions

View File

@ -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:
* <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 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());
List<String> rawNames = readToolRawNames(server);
List<String> 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<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_-]", "-");

View File

@ -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;

View File

@ -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:
* <ul>
* <li>Two MCP servers can expose the same raw tool name without one
* silently overwriting the other in a name-keyed map downstream.</li>
* <li>If two raw names within the same server happen to hash to the
* same prefixed name, only the first survives
* {@link McpHashCollisionDetector} flags the second so the picker
* can refuse to bind it.</li>
* </ul>
*/
public List<ToolCallback> getAllToolCallbacks() {
List<ToolCallback> allCallbacks = new ArrayList<>();
for (Map.Entry<Long, McpSyncClient> 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<raw, decision>} 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.
*
* <p>Package-private so unit tests can drive it without standing up a
* real {@link McpSyncClient}.
*/
static List<ToolCallback> wrapServerCallbacks(long serverId, ToolCallback[] cbs) {
List<String> rawNames = new ArrayList<>(cbs.length);
for (ToolCallback cb : cbs) {
rawNames.add(cb.getToolDefinition() != null ? cb.getToolDefinition().name() : null);
}
List<McpHashCollisionDetector.Decision> 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<ToolCallback> 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;
}
/**
* 获取连接结果
*/

View File

@ -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.
*
* <p>{@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:
*
* <ul>
* <li>{@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.</li>
* <li>{@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.</li>
* </ul>
*
* <p>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.
*
* <p>Stateless and thread-safe.
*/
public final class McpHashCollisionDetector {
private McpHashCollisionDetector() {}
/**
* Decide which raw tool names are bindable for a given server.
*
* <p>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<Decision> classify(long serverId, Collection<String> rawToolNames) {
if (rawToolNames == null || rawToolNames.isEmpty()) {
return List.of();
}
Map<String, String> firstRawByPrefixed = new LinkedHashMap<>();
List<Decision> 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_<serverId>_<slug>_<hash6>}
* @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) {}
}

View File

@ -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.
*
* <p>Format: {@code mcp_<serverId>_<slug>_<hash6>} where:
* <ul>
* <li>{@code <serverId>} 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.</li>
* <li>{@code <slug>} first 20 chars of {@code [^a-z0-9_-]'_'} on the
* lowercased raw tool name. Kept for human readability in logs / SQL.</li>
* <li>{@code <hash6>} 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.</li>
* </ul>
*
* <p>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.
*
* <p><b>The format is not a 1:1 string-only inverse of the raw name.</b>
* 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.
*
* <p>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) {}
}

View File

@ -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_<serverId>_<slug>_<hash6>} key.
*
* <p>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.
*
* <p>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;
}
}

View File

@ -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.
*
* <p>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<McpSchema.Tool> 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<McpServerEntity> 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<McpSchema.Tool> tools) {
if (tools == null || tools.isEmpty()) {
return "[]";
}
List<Map<String, Object>> rows = new ArrayList<>(tools.size());
for (McpSchema.Tool t : tools) {
if (t == null || t.name() == null || t.name().isBlank()) continue;
Map<String, Object> 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 名称不能为空");

View File

@ -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;

View File

@ -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;