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: + *
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 Package-private so unit tests can drive it without standing up a
+ * real {@link McpSyncClient}.
+ */
+ static List {@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:
+ *
+ * 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 Format: {@code mcp_ 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_ 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
+ *
*/
public List
+ *
+ *
+ *
+ *
+ *
+ *