mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
feat(mcp): stable prefixed callback names and persisted per-server tool cache
This commit is contained in:
parent
f5d25509bb
commit
845b5bb1b1
@ -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_-]", "-");
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取连接结果
|
||||
*/
|
||||
|
||||
@ -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) {}
|
||||
}
|
||||
@ -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) {}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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 名称不能为空");
|
||||
|
||||
@ -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;
|
||||
@ -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;
|
||||
Loading…
Reference in New Issue
Block a user