This is an automated email from the ASF dual-hosted git repository.

lizhimins pushed a commit to branch rocketmq-studio
in repository https://gitbox.apache.org/repos/asf/rocketmq-dashboard.git

commit c7dcc22240f3d5768c3a85610641739998e83c29
Author: PiliLily <[email protected]>
AuthorDate: Fri Jul 24 13:43:43 2026 +0800

    feat: add catalog-driven read-only tools (#514)
    
    Introduce versioned YAML tool catalog with JSON Schema validation, SHA-256 
digest, deep immutability, and two read-only L1 tool handlers (rmq.cluster.list 
/ rmq.capabilities).
---
 server/pom.xml                                     |   9 +
 .../common/exception/GlobalExceptionHandler.java   |   6 +-
 .../com/rocketmq/studio/ops/ai/AiController.java   |  27 +-
 .../java/com/rocketmq/studio/ops/ai/AiService.java |  23 ++
 .../java/com/rocketmq/studio/ops/ai/AiToolVO.java  |   9 +
 .../com/rocketmq/studio/ops/ai/McpServerImpl.java  |  61 ++--
 .../rocketmq/studio/ops/ai/McpServerRegistry.java  |  11 +
 .../ops/ai/tool/CapabilitiesToolHandler.java       |  55 ++++
 .../studio/ops/ai/tool/CapabilityResolver.java     |  64 ++++
 .../studio/ops/ai/tool/ClusterListToolHandler.java |  64 ++++
 .../rocketmq/studio/ops/ai/tool/ToolCatalog.java   | 183 ++++++++++++
 .../studio/ops/ai/tool/ToolDefinition.java         |  75 +++++
 .../studio/ops/ai/tool/ToolGatewayService.java     | 224 ++++++++++++++
 .../ToolHandler.java}                              |   9 +-
 .../resources/tool-catalog/rmq-tools.schema.json   | 139 +++++++++
 .../src/main/resources/tool-catalog/rmq-tools.yaml |  76 +++++
 .../rocketmq/studio/StudioApplicationTest.java}    |  24 +-
 .../exception/GlobalExceptionHandlerTest.java      |  67 +++++
 .../rocketmq/studio/ops/ai/AiControllerTest.java   | 128 ++++++++
 .../com/rocketmq/studio/ops/ai/AiServiceTest.java  |  22 ++
 .../studio/ops/ai/tool/ToolCatalogTest.java        | 158 ++++++++++
 .../studio/ops/ai/tool/ToolGatewayServiceTest.java | 326 +++++++++++++++++++++
 22 files changed, 1716 insertions(+), 44 deletions(-)

diff --git a/server/pom.xml b/server/pom.xml
index 55e3e94d..6450dcc0 100644
--- a/server/pom.xml
+++ b/server/pom.xml
@@ -30,6 +30,15 @@
             <groupId>org.springframework.boot</groupId>
             <artifactId>spring-boot-starter-validation</artifactId>
         </dependency>
+        <dependency>
+            <groupId>com.fasterxml.jackson.dataformat</groupId>
+            <artifactId>jackson-dataformat-yaml</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>com.networknt</groupId>
+            <artifactId>json-schema-validator</artifactId>
+            <version>2.0.4</version>
+        </dependency>
         <dependency>
             <groupId>org.springdoc</groupId>
             <artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
diff --git 
a/server/src/main/java/com/rocketmq/studio/common/exception/GlobalExceptionHandler.java
 
b/server/src/main/java/com/rocketmq/studio/common/exception/GlobalExceptionHandler.java
index 26cffb94..79eefd1b 100644
--- 
a/server/src/main/java/com/rocketmq/studio/common/exception/GlobalExceptionHandler.java
+++ 
b/server/src/main/java/com/rocketmq/studio/common/exception/GlobalExceptionHandler.java
@@ -34,10 +34,10 @@ public class GlobalExceptionHandler {
     private static final Logger log = 
LoggerFactory.getLogger(GlobalExceptionHandler.class);
 
     @ExceptionHandler(BusinessException.class)
-    @ResponseStatus(HttpStatus.BAD_REQUEST)
-    public Result<?> handleBusinessException(BusinessException ex) {
+    public ResponseEntity<Result<?>> handleBusinessException(BusinessException 
ex) {
         log.warn("Business exception: {}", ex.getMessage());
-        return Result.error(ex.getCode(), ex.getMessage());
+        return ResponseEntity.status(ex.getCode())
+                .body(Result.error(ex.getCode(), ex.getMessage()));
     }
 
     @ExceptionHandler(PrometheusException.class)
diff --git a/server/src/main/java/com/rocketmq/studio/ops/ai/AiController.java 
b/server/src/main/java/com/rocketmq/studio/ops/ai/AiController.java
index 27ce1461..3ddd7c8b 100644
--- a/server/src/main/java/com/rocketmq/studio/ops/ai/AiController.java
+++ b/server/src/main/java/com/rocketmq/studio/ops/ai/AiController.java
@@ -19,14 +19,19 @@ package com.rocketmq.studio.ops.ai;
 import com.rocketmq.studio.common.domain.Result;
 import lombok.RequiredArgsConstructor;
 import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
 import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
 import org.springframework.web.bind.annotation.PostMapping;
 import org.springframework.web.bind.annotation.RequestBody;
 import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
 import org.springframework.web.bind.annotation.RestController;
 import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
 
+import java.util.Collections;
 import java.util.List;
+import java.util.Map;
 
 @RestController
 @RequestMapping("/api/ai")
@@ -46,7 +51,25 @@ public class AiController {
     }
 
     @GetMapping("/tools")
-    public Result<List<AiToolVO>> listTools() {
-        return Result.ok(aiService.listTools());
+    public ResponseEntity<Result<List<AiToolVO>>> listTools(
+            @RequestParam(required = false) String cluster) {
+        List<AiToolVO> tools = cluster == null
+                ? aiService.listTools()
+                : aiService.listTools(cluster);
+        return ResponseEntity.ok()
+                .header("X-RMQ-Catalog-Version", aiService.catalogVersion())
+                .header("X-RMQ-Catalog-Digest", aiService.catalogDigest())
+                .header("X-RMQ-Minimum-Client-Version", 
aiService.minimumClientVersion())
+                .body(Result.ok(tools));
+    }
+
+    @PostMapping("/tools/{name}/execute")
+    public Result<Object> executeTool(
+            @PathVariable String name,
+            @RequestBody(required = false) Map<String, Object> input) {
+        Map<String, Object> normalizedInput = input == null
+                ? Collections.emptyMap()
+                : input;
+        return Result.ok(aiService.executeTool(name, normalizedInput));
     }
 }
diff --git a/server/src/main/java/com/rocketmq/studio/ops/ai/AiService.java 
b/server/src/main/java/com/rocketmq/studio/ops/ai/AiService.java
index b14479a7..3196f417 100644
--- a/server/src/main/java/com/rocketmq/studio/ops/ai/AiService.java
+++ b/server/src/main/java/com/rocketmq/studio/ops/ai/AiService.java
@@ -22,6 +22,7 @@ import org.springframework.stereotype.Service;
 import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
 
 import java.util.List;
+import java.util.Map;
 
 @Slf4j
 @Service
@@ -60,4 +61,26 @@ public class AiService {
         log.debug("Listing available AI tools");
         return mcpServerRegistry.listTools();
     }
+
+    public List<AiToolVO> listTools(String clusterId) {
+        log.debug("Listing available AI tools for cluster: {}", clusterId);
+        return mcpServerRegistry.listTools(clusterId);
+    }
+
+    public Object executeTool(String name, Map<String, Object> input) {
+        log.info("Executing registered AI tool: {}", name);
+        return mcpServerRegistry.execute(name, input);
+    }
+
+    public String catalogVersion() {
+        return mcpServerRegistry.catalogVersion();
+    }
+
+    public String catalogDigest() {
+        return mcpServerRegistry.catalogDigest();
+    }
+
+    public String minimumClientVersion() {
+        return mcpServerRegistry.minimumClientVersion();
+    }
 }
diff --git a/server/src/main/java/com/rocketmq/studio/ops/ai/AiToolVO.java 
b/server/src/main/java/com/rocketmq/studio/ops/ai/AiToolVO.java
index fc2dbe5c..3683ff44 100644
--- a/server/src/main/java/com/rocketmq/studio/ops/ai/AiToolVO.java
+++ b/server/src/main/java/com/rocketmq/studio/ops/ai/AiToolVO.java
@@ -21,6 +21,8 @@ import lombok.Builder;
 import lombok.Data;
 import lombok.NoArgsConstructor;
 
+import java.util.List;
+
 @Data
 @Builder
 @NoArgsConstructor
@@ -29,4 +31,11 @@ public class AiToolVO {
     private String name;
     private String description;
     private Object parameters;
+    private String riskLevel;
+    private String permission;
+    private List<String> requiredCapabilities;
+    private Object outputSchema;
+    private String viewHint;
+    private boolean deprecated;
+    private String replacement;
 }
diff --git a/server/src/main/java/com/rocketmq/studio/ops/ai/McpServerImpl.java 
b/server/src/main/java/com/rocketmq/studio/ops/ai/McpServerImpl.java
index 50aaa45f..d079e5e7 100644
--- a/server/src/main/java/com/rocketmq/studio/ops/ai/McpServerImpl.java
+++ b/server/src/main/java/com/rocketmq/studio/ops/ai/McpServerImpl.java
@@ -16,49 +16,48 @@
  */
 package com.rocketmq.studio.ops.ai;
 
-import lombok.extern.slf4j.Slf4j;
+import com.rocketmq.studio.ops.ai.tool.ToolCatalog;
+import com.rocketmq.studio.ops.ai.tool.ToolGatewayService;
+import lombok.RequiredArgsConstructor;
 import org.springframework.stereotype.Component;
 
-import java.util.Arrays;
-import java.util.Collections;
-import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
 
-@Slf4j
 @Component
+@RequiredArgsConstructor
 public class McpServerImpl implements McpServerRegistry {
 
+    private final ToolGatewayService toolGatewayService;
+    private final ToolCatalog toolCatalog;
+
     @Override
     public List<AiToolVO> listTools() {
-        log.debug("Listing available MCP tools (stub)");
+        return toolGatewayService.discover(null);
+    }
 
-        Map<String, Object> queryParams = new HashMap<>();
-        queryParams.put("type", "object");
-        queryParams.put("properties", Collections.singletonMap("query",
-                Collections.singletonMap("type", "string")));
+    @Override
+    public List<AiToolVO> listTools(String clusterId) {
+        return toolGatewayService.discover(clusterId);
+    }
 
-        Map<String, Object> brokerParams = new HashMap<>();
-        brokerParams.put("type", "object");
-        brokerParams.put("properties", Collections.singletonMap("brokerName",
-                Collections.singletonMap("type", "string")));
+    @Override
+    public Object execute(String name, Map<String, Object> input) {
+        return toolGatewayService.execute(name, input);
+    }
 
-        return Arrays.asList(
-                AiToolVO.builder()
-                        .name("query_metrics")
-                        .description("Query RocketMQ metrics from Prometheus")
-                        .parameters(queryParams)
-                        .build(),
-                AiToolVO.builder()
-                        .name("list_brokers")
-                        .description("List all RocketMQ brokers in the 
cluster")
-                        .parameters(Collections.emptyMap())
-                        .build(),
-                AiToolVO.builder()
-                        .name("diagnose_broker")
-                        .description("Diagnose issues with a specific broker")
-                        .parameters(brokerParams)
-                        .build()
-        );
+    @Override
+    public String catalogVersion() {
+        return toolCatalog.getVersion();
+    }
+
+    @Override
+    public String catalogDigest() {
+        return toolCatalog.getDigest();
+    }
+
+    @Override
+    public String minimumClientVersion() {
+        return toolCatalog.getMinimumClientVersion();
     }
 }
diff --git 
a/server/src/main/java/com/rocketmq/studio/ops/ai/McpServerRegistry.java 
b/server/src/main/java/com/rocketmq/studio/ops/ai/McpServerRegistry.java
index 24b5fff4..8dfcb81e 100644
--- a/server/src/main/java/com/rocketmq/studio/ops/ai/McpServerRegistry.java
+++ b/server/src/main/java/com/rocketmq/studio/ops/ai/McpServerRegistry.java
@@ -18,8 +18,19 @@ package com.rocketmq.studio.ops.ai;
 
 
 import java.util.List;
+import java.util.Map;
 
 public interface McpServerRegistry {
 
     List<AiToolVO> listTools();
+
+    List<AiToolVO> listTools(String clusterId);
+
+    Object execute(String name, Map<String, Object> input);
+
+    String catalogVersion();
+
+    String catalogDigest();
+
+    String minimumClientVersion();
 }
diff --git 
a/server/src/main/java/com/rocketmq/studio/ops/ai/tool/CapabilitiesToolHandler.java
 
b/server/src/main/java/com/rocketmq/studio/ops/ai/tool/CapabilitiesToolHandler.java
new file mode 100644
index 00000000..706087cd
--- /dev/null
+++ 
b/server/src/main/java/com/rocketmq/studio/ops/ai/tool/CapabilitiesToolHandler.java
@@ -0,0 +1,55 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.rocketmq.studio.ops.ai.tool;
+
+import com.rocketmq.studio.cluster.broker.ClusterService;
+import com.rocketmq.studio.cluster.broker.ClusterVO;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Component;
+
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+@Component
+@RequiredArgsConstructor
+public class CapabilitiesToolHandler implements ToolHandler {
+
+    private static final String NAME = "rmq.capabilities";
+
+    private final ClusterService clusterService;
+    private final CapabilityResolver capabilityResolver;
+
+    @Override
+    public String name() {
+        return NAME;
+    }
+
+    @Override
+    public Object execute(Map<String, Object> input) {
+        String clusterId = (String) input.get("cluster");
+        ClusterVO cluster = clusterService.getCluster(clusterId);
+        List<String> capabilities = capabilityResolver.resolve(cluster);
+
+        Map<String, Object> result = new LinkedHashMap<>();
+        result.put("cluster", cluster.getId());
+        result.put("type", cluster.getType().name());
+        result.put("version", cluster.getVersion());
+        result.put("capabilities", capabilities);
+        return result;
+    }
+}
diff --git 
a/server/src/main/java/com/rocketmq/studio/ops/ai/tool/CapabilityResolver.java 
b/server/src/main/java/com/rocketmq/studio/ops/ai/tool/CapabilityResolver.java
new file mode 100644
index 00000000..58254984
--- /dev/null
+++ 
b/server/src/main/java/com/rocketmq/studio/ops/ai/tool/CapabilityResolver.java
@@ -0,0 +1,64 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.rocketmq.studio.ops.ai.tool;
+
+import com.rocketmq.studio.cluster.broker.ClusterService;
+import com.rocketmq.studio.cluster.broker.ClusterVO;
+import com.rocketmq.studio.common.exception.BusinessException;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Component;
+
+import java.util.List;
+
+@Component
+@RequiredArgsConstructor
+public class CapabilityResolver {
+
+    private final ClusterService clusterService;
+
+    public List<String> resolve(String clusterId) {
+        return resolve(clusterService.getCluster(clusterId));
+    }
+
+    List<String> resolve(ClusterVO cluster) {
+        if (cluster.getType() == null) {
+            throw new BusinessException(
+                    400, "Cluster type is unavailable: " + cluster.getId());
+        }
+        return switch (cluster.getType()) {
+            case V4_DIRECT -> List.of(
+                    "REMOTING",
+                    "ROCKETMQ_4");
+            case V5_PROXY_LOCAL -> List.of(
+                    "ACL_V2",
+                    "GRPC",
+                    "LITE_TOPIC",
+                    "LOCAL_PROXY",
+                    "POP",
+                    "REMOTING",
+                    "ROCKETMQ_5");
+            case V5_PROXY_CLUSTER -> List.of(
+                    "ACL_V2",
+                    "CLUSTER_PROXY",
+                    "GRPC",
+                    "LITE_TOPIC",
+                    "POP",
+                    "REMOTING",
+                    "ROCKETMQ_5");
+        };
+    }
+}
diff --git 
a/server/src/main/java/com/rocketmq/studio/ops/ai/tool/ClusterListToolHandler.java
 
b/server/src/main/java/com/rocketmq/studio/ops/ai/tool/ClusterListToolHandler.java
new file mode 100644
index 00000000..47c313f5
--- /dev/null
+++ 
b/server/src/main/java/com/rocketmq/studio/ops/ai/tool/ClusterListToolHandler.java
@@ -0,0 +1,64 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.rocketmq.studio.ops.ai.tool;
+
+import com.rocketmq.studio.cluster.broker.ClusterService;
+import com.rocketmq.studio.cluster.broker.ClusterVO;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Component;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+@Component
+@RequiredArgsConstructor
+public class ClusterListToolHandler implements ToolHandler {
+
+    private static final String NAME = "rmq.cluster.list";
+
+    private final ClusterService clusterService;
+
+    @Override
+    public String name() {
+        return NAME;
+    }
+
+    @Override
+    public Object execute(Map<String, Object> input) {
+        return clusterService.listClusters().stream()
+                .map(ClusterListToolHandler::safeProjection)
+                .toList();
+    }
+
+    private static Map<String, Object> safeProjection(ClusterVO cluster) {
+        Map<String, Object> result = new LinkedHashMap<>();
+        result.put("id", cluster.getId());
+        result.put("name", cluster.getName());
+        result.put("type", requiredEnumName(cluster.getType(), "type", 
cluster.getId()));
+        result.put("status", requiredEnumName(cluster.getStatus(), "status", 
cluster.getId()));
+        result.put("version", cluster.getVersion());
+        return result;
+    }
+
+    private static String requiredEnumName(Enum<?> value, String field, String 
clusterId) {
+        if (value == null) {
+            throw new IllegalStateException(
+                    "Cluster " + field + " is unavailable: " + clusterId);
+        }
+        return value.name();
+    }
+}
diff --git 
a/server/src/main/java/com/rocketmq/studio/ops/ai/tool/ToolCatalog.java 
b/server/src/main/java/com/rocketmq/studio/ops/ai/tool/ToolCatalog.java
new file mode 100644
index 00000000..3e81e983
--- /dev/null
+++ b/server/src/main/java/com/rocketmq/studio/ops/ai/tool/ToolCatalog.java
@@ -0,0 +1,183 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.rocketmq.studio.ops.ai.tool;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
+import com.networknt.schema.Error;
+import com.networknt.schema.InputFormat;
+import com.networknt.schema.Schema;
+import com.networknt.schema.SchemaRegistry;
+import com.networknt.schema.SpecificationVersion;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.core.io.Resource;
+import org.springframework.core.io.ResourceLoader;
+import org.springframework.stereotype.Component;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.HexFormat;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+
+@Component
+public class ToolCatalog {
+
+    static final String CATALOG_RESOURCE = 
"classpath:tool-catalog/rmq-tools.yaml";
+    static final String SCHEMA_RESOURCE = 
"classpath:tool-catalog/rmq-tools.schema.json";
+
+    private static final String CLUSTER_LIST_TOOL = "rmq.cluster.list";
+    private static final ObjectMapper YAML_MAPPER = new ObjectMapper(new 
YAMLFactory());
+
+    private final String version;
+    private final String minimumClientVersion;
+    private final String digest;
+    private final List<ToolDefinition> definitions;
+    private final Map<String, ToolDefinition> definitionsByName;
+
+    @Autowired
+    public ToolCatalog(ResourceLoader resourceLoader) {
+        ToolCatalog loaded = load(
+                resourceLoader.getResource(CATALOG_RESOURCE),
+                resourceLoader.getResource(SCHEMA_RESOURCE));
+        this.version = loaded.version;
+        this.minimumClientVersion = loaded.minimumClientVersion;
+        this.digest = loaded.digest;
+        this.definitions = loaded.definitions;
+        this.definitionsByName = loaded.definitionsByName;
+    }
+
+    private ToolCatalog(
+            String version,
+            String minimumClientVersion,
+            String digest,
+            List<ToolDefinition> definitions,
+            Map<String, ToolDefinition> definitionsByName) {
+        this.version = version;
+        this.minimumClientVersion = minimumClientVersion;
+        this.digest = digest;
+        this.definitions = definitions;
+        this.definitionsByName = definitionsByName;
+    }
+
+    static ToolCatalog load(Resource catalogResource, Resource schemaResource) 
{
+        try {
+            byte[] catalogBytes = catalogResource.getContentAsByteArray();
+            String catalogYaml = new String(catalogBytes, 
StandardCharsets.UTF_8);
+            String schemaJson = 
schemaResource.getContentAsString(StandardCharsets.UTF_8);
+
+            SchemaRegistry registry = SchemaRegistry.withDefaultDialect(
+                    SpecificationVersion.DRAFT_2020_12);
+            Schema schema = registry.getSchema(schemaJson, InputFormat.JSON);
+            List<Error> validationErrors = new ArrayList<>(
+                    schema.validate(catalogYaml, InputFormat.YAML));
+            if (!validationErrors.isEmpty()) {
+                validationErrors.sort(Comparator.comparing(
+                        error -> error.getInstanceLocation().toString()));
+                throw new IllegalStateException(
+                        "Tool catalog schema validation failed: " + 
validationErrors);
+            }
+
+            CatalogDocument document = YAML_MAPPER.readValue(catalogBytes, 
CatalogDocument.class);
+            return validatedCatalog(document, sha256(catalogBytes));
+        } catch (IOException e) {
+            throw new IllegalStateException("Unable to load RocketMQ tool 
catalog", e);
+        }
+    }
+
+    private static ToolCatalog validatedCatalog(CatalogDocument document, 
String digest) {
+        int catalogMajor = majorVersion(document.version());
+        int minimumClientMajor = majorVersion(document.minimumClientVersion());
+        if (catalogMajor != minimumClientMajor) {
+            throw new IllegalStateException(
+                    "Catalog and minimum client major versions must match");
+        }
+
+        Map<String, ToolDefinition> byName = new LinkedHashMap<>();
+        for (ToolDefinition definition : document.tools()) {
+            if (byName.putIfAbsent(definition.name(), definition) != null) {
+                throw new IllegalStateException(
+                        "Tool catalog contains duplicate tool name: " + 
definition.name());
+            }
+            validateClusterConvention(definition);
+        }
+
+        List<ToolDefinition> immutableDefinitions = 
List.copyOf(byName.values());
+        return new ToolCatalog(
+                document.version(),
+                document.minimumClientVersion(),
+                digest,
+                immutableDefinitions,
+                Map.copyOf(byName));
+    }
+
+    private static void validateClusterConvention(ToolDefinition definition) {
+        if (CLUSTER_LIST_TOOL.equals(definition.name())) {
+            return;
+        }
+        Object required = definition.inputSchema().get("required");
+        if (!(required instanceof List<?> requiredFields) || 
!requiredFields.contains("cluster")) {
+            throw new IllegalStateException(
+                    "Remote tool must require cluster: " + definition.name());
+        }
+    }
+
+    private static int majorVersion(String version) {
+        return Integer.parseInt(version.substring(0, version.indexOf('.')));
+    }
+
+    private static String sha256(byte[] bytes) {
+        try {
+            MessageDigest digest = MessageDigest.getInstance("SHA-256");
+            return HexFormat.of().formatHex(digest.digest(bytes));
+        } catch (NoSuchAlgorithmException e) {
+            throw new IllegalStateException("SHA-256 is unavailable", e);
+        }
+    }
+
+    public String getVersion() {
+        return version;
+    }
+
+    public String getMinimumClientVersion() {
+        return minimumClientVersion;
+    }
+
+    public String getDigest() {
+        return digest;
+    }
+
+    public List<ToolDefinition> list() {
+        return definitions;
+    }
+
+    public Optional<ToolDefinition> find(String name) {
+        return Optional.ofNullable(definitionsByName.get(name));
+    }
+
+    private record CatalogDocument(
+            String version,
+            String minimumClientVersion,
+            List<ToolDefinition> tools) {
+    }
+}
diff --git 
a/server/src/main/java/com/rocketmq/studio/ops/ai/tool/ToolDefinition.java 
b/server/src/main/java/com/rocketmq/studio/ops/ai/tool/ToolDefinition.java
new file mode 100644
index 00000000..493e02ed
--- /dev/null
+++ b/server/src/main/java/com/rocketmq/studio/ops/ai/tool/ToolDefinition.java
@@ -0,0 +1,75 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.rocketmq.studio.ops.ai.tool;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+public record ToolDefinition(
+        String name,
+        Cli cli,
+        String description,
+        String riskLevel,
+        String permission,
+        List<String> requiredCapabilities,
+        Map<String, Object> inputSchema,
+        Map<String, Object> outputSchema,
+        String viewHint,
+        boolean deprecated,
+        String replacement) {
+
+    public ToolDefinition {
+        requiredCapabilities = List.copyOf(requiredCapabilities);
+        inputSchema = immutableMap(inputSchema);
+        outputSchema = immutableMap(outputSchema);
+    }
+
+    public String getName() {
+        return name;
+    }
+
+    private static Map<String, Object> immutableMap(Map<String, Object> 
source) {
+        Map<String, Object> copy = new LinkedHashMap<>();
+        source.forEach((key, value) -> copy.put(key, immutableValue(value)));
+        return Collections.unmodifiableMap(copy);
+    }
+
+    private static Object immutableValue(Object value) {
+        if (value instanceof Map<?, ?> nestedMap) {
+            Map<String, Object> copy = new LinkedHashMap<>();
+            nestedMap.forEach((key, nestedValue) -> {
+                if (!(key instanceof String stringKey)) {
+                    throw new IllegalArgumentException("JSON Schema keys must 
be strings");
+                }
+                copy.put(stringKey, immutableValue(nestedValue));
+            });
+            return Collections.unmodifiableMap(copy);
+        }
+        if (value instanceof List<?> nestedList) {
+            List<Object> copy = new ArrayList<>(nestedList.size());
+            nestedList.forEach(item -> copy.add(immutableValue(item)));
+            return Collections.unmodifiableList(copy);
+        }
+        return value;
+    }
+
+    public record Cli(String resource, String verb) {
+    }
+}
diff --git 
a/server/src/main/java/com/rocketmq/studio/ops/ai/tool/ToolGatewayService.java 
b/server/src/main/java/com/rocketmq/studio/ops/ai/tool/ToolGatewayService.java
new file mode 100644
index 00000000..c8de44b9
--- /dev/null
+++ 
b/server/src/main/java/com/rocketmq/studio/ops/ai/tool/ToolGatewayService.java
@@ -0,0 +1,224 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.rocketmq.studio.ops.ai.tool;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.networknt.schema.Error;
+import com.networknt.schema.Schema;
+import com.networknt.schema.SchemaLocation;
+import com.networknt.schema.SchemaRegistry;
+import com.networknt.schema.SpecificationVersion;
+import com.networknt.schema.dialect.Dialects;
+import com.rocketmq.studio.common.exception.BusinessException;
+import com.rocketmq.studio.ops.ai.AiToolVO;
+import org.springframework.stereotype.Service;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+@Service
+public class ToolGatewayService {
+
+    private static final String L1 = "L1";
+
+    private final ToolCatalog catalog;
+    private final CapabilityResolver capabilityResolver;
+    private final ObjectMapper objectMapper;
+    private final Map<String, ToolHandler> handlers;
+    private final Map<String, Schema> inputSchemas;
+    private final Map<String, Schema> outputSchemas;
+
+    public ToolGatewayService(
+            ToolCatalog catalog,
+            CapabilityResolver capabilityResolver,
+            ObjectMapper objectMapper,
+            List<ToolHandler> handlers) {
+        this.catalog = catalog;
+        this.capabilityResolver = capabilityResolver;
+        this.objectMapper = objectMapper;
+        this.handlers = registerHandlers(catalog, handlers);
+
+        SchemaRegistry registry = SchemaRegistry.withDefaultDialect(
+                SpecificationVersion.DRAFT_2020_12);
+        SchemaRegistry metaSchemaRegistry = SchemaRegistry.withDialect(
+                Dialects.getDraft202012());
+        Schema metaSchema = metaSchemaRegistry.getSchema(
+                SchemaLocation.of(Dialects.getDraft202012().getId()));
+        this.inputSchemas = compileSchemas(catalog, registry, metaSchema, 
true);
+        this.outputSchemas = compileSchemas(catalog, registry, metaSchema, 
false);
+    }
+
+    public List<AiToolVO> discover(String clusterId) {
+        boolean clusterSelected = clusterId != null && !clusterId.isBlank();
+        Set<String> capabilities = clusterSelected
+                ? Set.copyOf(capabilityResolver.resolve(clusterId))
+                : Collections.emptySet();
+
+        return catalog.list().stream()
+                .filter(definition -> clusterSelected || 
!requiresCluster(definition))
+                .filter(definition -> capabilities.containsAll(
+                        definition.requiredCapabilities()))
+                .map(ToolGatewayService::toView)
+                .toList();
+    }
+
+    public Object execute(String name, Map<String, Object> input) {
+        ToolDefinition definition = catalog.find(name)
+                .orElseThrow(() -> new BusinessException(404, "Tool not found: 
" + name));
+        ToolHandler handler = handlers.get(name);
+        Map<String, Object> normalizedInput = input == null
+                ? Collections.emptyMap()
+                : input;
+
+        validateInput(definition, normalizedInput);
+        if (!L1.equals(definition.riskLevel())) {
+            throw new BusinessException(
+                    400, "Execution rejected; only L1 tools are enabled: " + 
name);
+        }
+        enforceCapabilities(definition, normalizedInput);
+
+        Object output = handler.execute(normalizedInput);
+        validateOutput(definition, output);
+        return output;
+    }
+
+    private void validateInput(ToolDefinition definition, Map<String, Object> 
input) {
+        List<Error> errors = sortedErrors(
+                
inputSchemas.get(definition.name()).validate(objectMapper.valueToTree(input)));
+        if (!errors.isEmpty()) {
+            throw new BusinessException(
+                    400,
+                    "Tool input validation failed for " + definition.name() + 
": " + errors);
+        }
+    }
+
+    private void enforceCapabilities(
+            ToolDefinition definition,
+            Map<String, Object> input) {
+        if (definition.requiredCapabilities().isEmpty()) {
+            return;
+        }
+        Object cluster = input.get("cluster");
+        if (!(cluster instanceof String clusterId) || clusterId.isBlank()) {
+            throw new BusinessException(
+                    400, "Tool requires a cluster for capability checks: " + 
definition.name());
+        }
+        Set<String> capabilities = 
Set.copyOf(capabilityResolver.resolve(clusterId));
+        if (!capabilities.containsAll(definition.requiredCapabilities())) {
+            throw new BusinessException(
+                    400, "Cluster does not support tool: " + 
definition.name());
+        }
+    }
+
+    private void validateOutput(ToolDefinition definition, Object output) {
+        JsonNode outputNode = objectMapper.valueToTree(output);
+        List<Error> errors = sortedErrors(
+                outputSchemas.get(definition.name()).validate(outputNode));
+        if (!errors.isEmpty()) {
+            throw new IllegalStateException(
+                    "Tool output validation failed for " + definition.name() + 
": " + errors);
+        }
+    }
+
+    private static Map<String, ToolHandler> registerHandlers(
+            ToolCatalog catalog,
+            List<ToolHandler> handlers) {
+        Map<String, ToolHandler> registered = new LinkedHashMap<>();
+        for (ToolHandler handler : handlers) {
+            if (registered.putIfAbsent(handler.name(), handler) != null) {
+                throw new IllegalStateException(
+                        "Tool gateway contains duplicate handler: " + 
handler.name());
+            }
+            if (catalog.find(handler.name()).isEmpty()) {
+                throw new IllegalStateException(
+                        "Tool handler is absent from catalog: " + 
handler.name());
+            }
+        }
+
+        List<String> missing = catalog.list().stream()
+                .map(ToolDefinition::name)
+                .filter(name -> !registered.containsKey(name))
+                .toList();
+        if (!missing.isEmpty()) {
+            throw new IllegalStateException(
+                    "Tool catalog contains missing handler: " + missing);
+        }
+        return Collections.unmodifiableMap(registered);
+    }
+
+    private Map<String, Schema> compileSchemas(
+            ToolCatalog catalog,
+            SchemaRegistry registry,
+            Schema metaSchema,
+            boolean input) {
+        String schemaKind = input ? "input" : "output";
+        Map<String, Schema> compiled = new LinkedHashMap<>();
+        for (ToolDefinition definition : catalog.list()) {
+            JsonNode schemaNode = objectMapper.valueToTree(
+                    input ? definition.inputSchema() : 
definition.outputSchema());
+            List<Error> metaSchemaErrors = 
sortedErrors(metaSchema.validate(schemaNode));
+            if (!metaSchemaErrors.isEmpty()) {
+                throw new IllegalStateException(
+                        "Tool " + schemaKind + " schema is invalid for "
+                                + definition.name() + ": " + metaSchemaErrors);
+            }
+
+            try {
+                Schema schema = registry.getSchema(schemaNode);
+                schema.initializeValidators();
+                compiled.put(definition.name(), schema);
+            } catch (RuntimeException ex) {
+                throw new IllegalStateException(
+                        "Tool " + schemaKind + " schema is invalid for "
+                                + definition.name(), ex);
+            }
+        }
+        return Collections.unmodifiableMap(compiled);
+    }
+
+    private static boolean requiresCluster(ToolDefinition definition) {
+        Object required = definition.inputSchema().get("required");
+        return required instanceof List<?> fields && 
fields.contains("cluster");
+    }
+
+    private static AiToolVO toView(ToolDefinition definition) {
+        return AiToolVO.builder()
+                .name(definition.name())
+                .description(definition.description())
+                .parameters(definition.inputSchema())
+                .riskLevel(definition.riskLevel())
+                .permission(definition.permission())
+                .requiredCapabilities(definition.requiredCapabilities())
+                .outputSchema(definition.outputSchema())
+                .viewHint(definition.viewHint())
+                .deprecated(definition.deprecated())
+                .replacement(definition.replacement())
+                .build();
+    }
+
+    private static List<Error> sortedErrors(List<Error> errors) {
+        List<Error> sorted = new ArrayList<>(errors);
+        sorted.sort(Comparator.comparing(error -> 
error.getInstanceLocation().toString()));
+        return sorted;
+    }
+}
diff --git 
a/server/src/main/java/com/rocketmq/studio/ops/ai/McpServerRegistry.java 
b/server/src/main/java/com/rocketmq/studio/ops/ai/tool/ToolHandler.java
similarity index 83%
copy from server/src/main/java/com/rocketmq/studio/ops/ai/McpServerRegistry.java
copy to server/src/main/java/com/rocketmq/studio/ops/ai/tool/ToolHandler.java
index 24b5fff4..3a6f8af2 100644
--- a/server/src/main/java/com/rocketmq/studio/ops/ai/McpServerRegistry.java
+++ b/server/src/main/java/com/rocketmq/studio/ops/ai/tool/ToolHandler.java
@@ -14,12 +14,13 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-package com.rocketmq.studio.ops.ai;
+package com.rocketmq.studio.ops.ai.tool;
 
+import java.util.Map;
 
-import java.util.List;
+public interface ToolHandler {
 
-public interface McpServerRegistry {
+    String name();
 
-    List<AiToolVO> listTools();
+    Object execute(Map<String, Object> input);
 }
diff --git a/server/src/main/resources/tool-catalog/rmq-tools.schema.json 
b/server/src/main/resources/tool-catalog/rmq-tools.schema.json
new file mode 100644
index 00000000..f192ffc0
--- /dev/null
+++ b/server/src/main/resources/tool-catalog/rmq-tools.schema.json
@@ -0,0 +1,139 @@
+{
+  "$schema": "https://json-schema.org/draft/2020-12/schema";,
+  "$id": "https://rocketmq.apache.org/schemas/rmq-tools.schema.json";,
+  "title": "RocketMQ Studio tool catalog",
+  "type": "object",
+  "additionalProperties": false,
+  "required": [
+    "version",
+    "minimumClientVersion",
+    "tools"
+  ],
+  "properties": {
+    "version": {
+      "$ref": "#/$defs/semanticVersion"
+    },
+    "minimumClientVersion": {
+      "$ref": "#/$defs/semanticVersion"
+    },
+    "tools": {
+      "type": "array",
+      "minItems": 1,
+      "items": {
+        "$ref": "#/$defs/tool"
+      }
+    }
+  },
+  "$defs": {
+    "semanticVersion": {
+      "type": "string",
+      "pattern": 
"^[0-9]+\\.[0-9]+\\.[0-9]+(?:-[0-9A-Za-z.-]+)?(?:\\+[0-9A-Za-z.-]+)?$"
+    },
+    "toolName": {
+      "type": "string",
+      "pattern": "^rmq\\.[a-z][a-z0-9]*(\\.[a-z][a-z0-9]*)*$"
+    },
+    "cli": {
+      "type": "object",
+      "additionalProperties": false,
+      "required": [
+        "resource",
+        "verb"
+      ],
+      "properties": {
+        "resource": {
+          "type": "string",
+          "pattern": "^[a-z][a-z0-9-]*$"
+        },
+        "verb": {
+          "type": "string",
+          "pattern": "^[a-z][a-z0-9-]*$"
+        }
+      }
+    },
+    "tool": {
+      "type": "object",
+      "additionalProperties": false,
+      "required": [
+        "name",
+        "cli",
+        "description",
+        "riskLevel",
+        "permission",
+        "requiredCapabilities",
+        "inputSchema",
+        "outputSchema",
+        "viewHint",
+        "deprecated"
+      ],
+      "properties": {
+        "name": {
+          "$ref": "#/$defs/toolName"
+        },
+        "cli": {
+          "$ref": "#/$defs/cli"
+        },
+        "description": {
+          "type": "string",
+          "minLength": 1
+        },
+        "riskLevel": {
+          "enum": [
+            "L1",
+            "L2",
+            "L3"
+          ]
+        },
+        "permission": {
+          "type": "string",
+          "pattern": "^[a-z][a-z0-9-]*:[a-z][a-z0-9-]*$"
+        },
+        "requiredCapabilities": {
+          "type": "array",
+          "uniqueItems": true,
+          "items": {
+            "type": "string",
+            "pattern": "^[A-Z][A-Z0-9_]*$"
+          }
+        },
+        "inputSchema": {
+          "type": "object"
+        },
+        "outputSchema": {
+          "type": "object"
+        },
+        "viewHint": {
+          "enum": [
+            "object",
+            "table",
+            "text",
+            "timeline",
+            "topology"
+          ]
+        },
+        "deprecated": {
+          "type": "boolean"
+        },
+        "replacement": {
+          "$ref": "#/$defs/toolName"
+        }
+      },
+      "allOf": [
+        {
+          "if": {
+            "required": [
+              "replacement"
+            ]
+          },
+          "then": {
+            "properties": {
+              "deprecated": {
+                "const": true
+              }
+            }
+          }
+        }
+      ]
+    }
+  }
+}
diff --git a/server/src/main/resources/tool-catalog/rmq-tools.yaml 
b/server/src/main/resources/tool-catalog/rmq-tools.yaml
new file mode 100644
index 00000000..4327667c
--- /dev/null
+++ b/server/src/main/resources/tool-catalog/rmq-tools.yaml
@@ -0,0 +1,76 @@
+version: 1.0.0
+minimumClientVersion: 1.0.0
+tools:
+  - name: rmq.cluster.list
+    cli:
+      resource: cluster
+      verb: list
+    description: List RocketMQ clusters available in Studio.
+    riskLevel: L1
+    permission: cluster:read
+    requiredCapabilities: []
+    inputSchema:
+      type: object
+      additionalProperties: false
+    outputSchema:
+      type: array
+      items:
+        type: object
+        required:
+          - id
+          - name
+          - type
+          - status
+          - version
+        additionalProperties: false
+        properties:
+          id:
+            type: string
+          name:
+            type: string
+          type:
+            type: string
+          status:
+            type: string
+          version:
+            type: string
+    viewHint: table
+    deprecated: false
+  - name: rmq.capabilities
+    cli:
+      resource: capabilities
+      verb: get
+    description: Describe the capabilities of one RocketMQ cluster.
+    riskLevel: L1
+    permission: cluster:read
+    requiredCapabilities: []
+    inputSchema:
+      type: object
+      required:
+        - cluster
+      additionalProperties: false
+      properties:
+        cluster:
+          type: string
+          minLength: 1
+    outputSchema:
+      type: object
+      required:
+        - cluster
+        - type
+        - version
+        - capabilities
+      additionalProperties: false
+      properties:
+        cluster:
+          type: string
+        type:
+          type: string
+        version:
+          type: string
+        capabilities:
+          type: array
+          items:
+            type: string
+    viewHint: object
+    deprecated: false
diff --git 
a/server/src/main/java/com/rocketmq/studio/ops/ai/McpServerRegistry.java 
b/server/src/test/java/com/rocketmq/studio/StudioApplicationTest.java
similarity index 52%
copy from server/src/main/java/com/rocketmq/studio/ops/ai/McpServerRegistry.java
copy to server/src/test/java/com/rocketmq/studio/StudioApplicationTest.java
index 24b5fff4..3f67d482 100644
--- a/server/src/main/java/com/rocketmq/studio/ops/ai/McpServerRegistry.java
+++ b/server/src/test/java/com/rocketmq/studio/StudioApplicationTest.java
@@ -14,12 +14,28 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-package com.rocketmq.studio.ops.ai;
+package com.rocketmq.studio;
 
+import com.rocketmq.studio.ops.ai.tool.ToolCatalog;
+import com.rocketmq.studio.ops.ai.tool.ToolGatewayService;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
 
-import java.util.List;
+import static org.assertj.core.api.Assertions.assertThat;
 
-public interface McpServerRegistry {
+@SpringBootTest
+class StudioApplicationTest {
 
-    List<AiToolVO> listTools();
+    @Autowired
+    private ToolCatalog toolCatalog;
+
+    @Autowired
+    private ToolGatewayService toolGatewayService;
+
+    @Test
+    void applicationContextLoadsWithToolGateway() {
+        assertThat(toolCatalog.getVersion()).isEqualTo("1.0.0");
+        assertThat(toolGatewayService.discover(null)).isNotEmpty();
+    }
 }
diff --git 
a/server/src/test/java/com/rocketmq/studio/common/exception/GlobalExceptionHandlerTest.java
 
b/server/src/test/java/com/rocketmq/studio/common/exception/GlobalExceptionHandlerTest.java
new file mode 100644
index 00000000..3b5f525c
--- /dev/null
+++ 
b/server/src/test/java/com/rocketmq/studio/common/exception/GlobalExceptionHandlerTest.java
@@ -0,0 +1,67 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.rocketmq.studio.common.exception;
+
+import com.rocketmq.studio.common.domain.Result;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.setup.MockMvcBuilders;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.RestController;
+
+import static 
org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static 
org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static 
org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+class GlobalExceptionHandlerTest {
+
+    private MockMvc mockMvc;
+
+    @BeforeEach
+    void setUp() {
+        mockMvc = MockMvcBuilders.standaloneSetup(new FailingController())
+                .setControllerAdvice(new GlobalExceptionHandler())
+                .build();
+    }
+
+    @Test
+    void preservesNotFoundBusinessStatusAndEnvelope() throws Exception {
+        mockMvc.perform(get("/test/business/404"))
+                .andExpect(status().isNotFound())
+                .andExpect(jsonPath("$.code").value(404))
+                .andExpect(jsonPath("$.message").value("failure-404"));
+    }
+
+    @Test
+    void preservesBadRequestBusinessStatusAndEnvelope() throws Exception {
+        mockMvc.perform(get("/test/business/400"))
+                .andExpect(status().isBadRequest())
+                .andExpect(jsonPath("$.code").value(400))
+                .andExpect(jsonPath("$.message").value("failure-400"));
+    }
+
+    @RestController
+    static class FailingController {
+
+        @GetMapping("/test/business/{code}")
+        Result<Void> fail(@PathVariable int code) {
+            throw new BusinessException(code, "failure-" + code);
+        }
+    }
+}
diff --git 
a/server/src/test/java/com/rocketmq/studio/ops/ai/AiControllerTest.java 
b/server/src/test/java/com/rocketmq/studio/ops/ai/AiControllerTest.java
new file mode 100644
index 00000000..7b9e0277
--- /dev/null
+++ b/server/src/test/java/com/rocketmq/studio/ops/ai/AiControllerTest.java
@@ -0,0 +1,128 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.rocketmq.studio.ops.ai;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.http.MediaType;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.setup.MockMvcBuilders;
+
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+import static 
org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static 
org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static 
org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
+import static 
org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static 
org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+class AiControllerTest {
+
+    private static final String DIGEST = "a".repeat(64);
+
+    private AiService aiService;
+    private MockMvc mockMvc;
+
+    @BeforeEach
+    void setUp() {
+        aiService = mock(AiService.class);
+        mockMvc = MockMvcBuilders.standaloneSetup(new 
AiController(aiService)).build();
+
+        when(aiService.catalogVersion()).thenReturn("1.0.0");
+        when(aiService.catalogDigest()).thenReturn(DIGEST);
+        when(aiService.minimumClientVersion()).thenReturn("1.0.0");
+    }
+
+    @Test
+    void listToolsKeepsTheExistingBodyAndAddsCatalogHeaders() throws Exception 
{
+        AiToolVO tool = AiToolVO.builder()
+                .name("rmq.cluster.list")
+                .description("List clusters")
+                .parameters(Map.of("type", "object"))
+                .riskLevel("L1")
+                .permission("cluster:read")
+                .requiredCapabilities(Collections.emptyList())
+                .outputSchema(Map.of("type", "array"))
+                .viewHint("table")
+                .deprecated(false)
+                .build();
+        when(aiService.listTools()).thenReturn(List.of(tool));
+
+        mockMvc.perform(get("/api/ai/tools"))
+                .andExpect(status().isOk())
+                .andExpect(header().string("X-RMQ-Catalog-Version", "1.0.0"))
+                .andExpect(header().string("X-RMQ-Catalog-Digest", DIGEST))
+                .andExpect(header().string("X-RMQ-Minimum-Client-Version", 
"1.0.0"))
+                .andExpect(jsonPath("$.code").value(200))
+                .andExpect(jsonPath("$.message").value("success"))
+                
.andExpect(jsonPath("$.data[0].name").value("rmq.cluster.list"))
+                
.andExpect(jsonPath("$.data[0].parameters.type").value("object"))
+                .andExpect(jsonPath("$.data[0].riskLevel").value("L1"))
+                
.andExpect(jsonPath("$.data[0].permission").value("cluster:read"))
+                .andExpect(jsonPath("$.data[0].viewHint").value("table"));
+    }
+
+    @Test
+    void listToolsDelegatesTheSelectedCluster() throws Exception {
+        
when(aiService.listTools("cluster-001")).thenReturn(Collections.emptyList());
+
+        mockMvc.perform(get("/api/ai/tools").queryParam("cluster", 
"cluster-001"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.data").isArray());
+
+        verify(aiService).listTools("cluster-001");
+    }
+
+    @Test
+    void executeToolPreservesStructuredInputAndDottedName() throws Exception {
+        Map<String, Object> input = Map.of("cluster", "cluster-001");
+        Map<String, Object> output = Map.of(
+                "cluster", "cluster-001",
+                "capabilities", List.of("GRPC"));
+        when(aiService.executeTool("rmq.capabilities", 
input)).thenReturn(output);
+
+        mockMvc.perform(post("/api/ai/tools/rmq.capabilities/execute")
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content("""
+                                {"cluster":"cluster-001"}
+                                """))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(200))
+                .andExpect(jsonPath("$.data.cluster").value("cluster-001"))
+                .andExpect(jsonPath("$.data.capabilities[0]").value("GRPC"));
+
+        verify(aiService).executeTool("rmq.capabilities", input);
+    }
+
+    @Test
+    void executeClusterListNormalizesAnAbsentBody() throws Exception {
+        when(aiService.executeTool("rmq.cluster.list", Collections.emptyMap()))
+                .thenReturn(Collections.emptyList());
+
+        mockMvc.perform(post("/api/ai/tools/rmq.cluster.list/execute")
+                        .contentType(MediaType.APPLICATION_JSON))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.data").isArray());
+
+        verify(aiService).executeTool("rmq.cluster.list", 
Collections.emptyMap());
+    }
+}
diff --git a/server/src/test/java/com/rocketmq/studio/ops/ai/AiServiceTest.java 
b/server/src/test/java/com/rocketmq/studio/ops/ai/AiServiceTest.java
index ac46c7d1..a7f46a48 100644
--- a/server/src/test/java/com/rocketmq/studio/ops/ai/AiServiceTest.java
+++ b/server/src/test/java/com/rocketmq/studio/ops/ai/AiServiceTest.java
@@ -181,4 +181,26 @@ class AiServiceTest {
         assertThat(result.get(0).getParameters()).isNotNull();
         assertThat(result.get(0).getParameters()).isInstanceOf(Map.class);
     }
+
+    @Test
+    void listToolsForClusterDelegatesClusterSelection() {
+        
when(mcpServerRegistry.listTools("cluster-001")).thenReturn(Collections.emptyList());
+
+        List<AiToolVO> result = aiService.listTools("cluster-001");
+
+        assertThat(result).isEmpty();
+        verify(mcpServerRegistry).listTools("cluster-001");
+    }
+
+    @Test
+    void executeToolDelegatesStructuredInput() {
+        Map<String, Object> input = Map.of("cluster", "cluster-001");
+        Map<String, Object> output = Map.of("cluster", "cluster-001");
+        when(mcpServerRegistry.execute("rmq.capabilities", 
input)).thenReturn(output);
+
+        Object result = aiService.executeTool("rmq.capabilities", input);
+
+        assertThat(result).isSameAs(output);
+        verify(mcpServerRegistry).execute("rmq.capabilities", input);
+    }
 }
diff --git 
a/server/src/test/java/com/rocketmq/studio/ops/ai/tool/ToolCatalogTest.java 
b/server/src/test/java/com/rocketmq/studio/ops/ai/tool/ToolCatalogTest.java
new file mode 100644
index 00000000..285965b8
--- /dev/null
+++ b/server/src/test/java/com/rocketmq/studio/ops/ai/tool/ToolCatalogTest.java
@@ -0,0 +1,158 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.rocketmq.studio.ops.ai.tool;
+
+import org.junit.jupiter.api.Test;
+import org.springframework.core.io.ByteArrayResource;
+import org.springframework.core.io.ClassPathResource;
+import org.springframework.core.io.Resource;
+
+import java.nio.charset.StandardCharsets;
+import java.util.List;
+import java.util.Map;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+class ToolCatalogTest {
+
+    @Test
+    void loadsAndIndexesTheCanonicalCatalog() {
+        ToolCatalog catalog = ToolCatalog.load(canonicalCatalog(), 
canonicalSchema());
+
+        assertThat(catalog.getVersion()).isEqualTo("1.0.0");
+        assertThat(catalog.getMinimumClientVersion()).isEqualTo("1.0.0");
+        assertThat(catalog.getDigest()).matches("[0-9a-f]{64}");
+        assertThat(catalog.list()).extracting(ToolDefinition::getName)
+                .containsExactly("rmq.cluster.list", "rmq.capabilities");
+        assertThat(catalog.find("rmq.cluster.list")).isPresent();
+        assertThat(catalog.find("rmq.unknown")).isEmpty();
+    }
+
+    @Test
+    void rejectsCatalogThatDoesNotMatchItsJsonSchema() {
+        Resource invalid = utf8Resource("""
+                version: 1.0.0
+                minimumClientVersion: 1.0.0
+                tools: not-a-list
+                """);
+
+        assertThatThrownBy(() -> ToolCatalog.load(invalid, canonicalSchema()))
+                .isInstanceOf(IllegalStateException.class)
+                .hasMessageContaining("catalog schema validation failed");
+    }
+
+    @Test
+    void rejectsDuplicateToolNames() {
+        Resource duplicate = catalog(
+                tool("rmq.cluster.list", false),
+                tool("rmq.cluster.list", false));
+
+        assertThatThrownBy(() -> ToolCatalog.load(duplicate, 
canonicalSchema()))
+                .isInstanceOf(IllegalStateException.class)
+                .hasMessageContaining("duplicate tool name");
+    }
+
+    @Test
+    void requiresClusterForEveryRemoteToolExceptClusterList() {
+        Resource missingCluster = catalog(tool("rmq.capabilities", false));
+
+        assertThatThrownBy(() -> ToolCatalog.load(missingCluster, 
canonicalSchema()))
+                .isInstanceOf(IllegalStateException.class)
+                .hasMessageContaining("must require cluster");
+    }
+
+    @Test
+    void rejectsIncompatibleMinimumClientMajorVersion() {
+        Resource incompatible = utf8Resource("""
+                version: 2.0.0
+                minimumClientVersion: 1.0.0
+                tools:
+                %s
+                """.formatted(tool("rmq.cluster.list", false)));
+
+        assertThatThrownBy(() -> ToolCatalog.load(incompatible, 
canonicalSchema()))
+                .isInstanceOf(IllegalStateException.class)
+                .hasMessageContaining("major versions must match");
+    }
+
+    @Test
+    @SuppressWarnings("unchecked")
+    void definitionsAreDeeplyImmutableAfterStartupValidation() {
+        ToolDefinition capabilities = ToolCatalog.load(canonicalCatalog(), 
canonicalSchema())
+                .find("rmq.capabilities")
+                .orElseThrow();
+        Map<String, Object> properties =
+                (Map<String, Object>) 
capabilities.inputSchema().get("properties");
+        List<String> required =
+                (List<String>) capabilities.inputSchema().get("required");
+
+        assertThatThrownBy(() -> properties.put("endpoint", Map.of("type", 
"string")))
+                .isInstanceOf(UnsupportedOperationException.class);
+        assertThatThrownBy(() -> required.add("endpoint"))
+                .isInstanceOf(UnsupportedOperationException.class);
+    }
+
+    private static Resource canonicalCatalog() {
+        return new ClassPathResource("tool-catalog/rmq-tools.yaml");
+    }
+
+    private static Resource canonicalSchema() {
+        return new ClassPathResource("tool-catalog/rmq-tools.schema.json");
+    }
+
+    private static Resource catalog(String... tools) {
+        return utf8Resource("""
+                version: 1.0.0
+                minimumClientVersion: 1.0.0
+                tools:
+                %s
+                """.formatted(String.join("\n", tools)));
+    }
+
+    private static String tool(String name, boolean clusterRequired) {
+        String required = clusterRequired ? """
+                    required:
+                      - cluster
+                    properties:
+                      cluster:
+                        type: string
+                        minLength: 1
+                """ : "";
+        return """
+                  - name: %s
+                    cli:
+                      resource: cluster
+                      verb: list
+                    description: Test tool.
+                    riskLevel: L1
+                    permission: cluster:read
+                    requiredCapabilities: []
+                    inputSchema:
+                      type: object
+                %s      additionalProperties: false
+                    outputSchema:
+                      type: object
+                    viewHint: object
+                    deprecated: false
+                """.formatted(name, required);
+    }
+
+    private static Resource utf8Resource(String value) {
+        return new ByteArrayResource(value.getBytes(StandardCharsets.UTF_8));
+    }
+}
diff --git 
a/server/src/test/java/com/rocketmq/studio/ops/ai/tool/ToolGatewayServiceTest.java
 
b/server/src/test/java/com/rocketmq/studio/ops/ai/tool/ToolGatewayServiceTest.java
new file mode 100644
index 00000000..7d7cb37c
--- /dev/null
+++ 
b/server/src/test/java/com/rocketmq/studio/ops/ai/tool/ToolGatewayServiceTest.java
@@ -0,0 +1,326 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.rocketmq.studio.ops.ai.tool;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.rocketmq.studio.cluster.broker.ClusterService;
+import com.rocketmq.studio.cluster.broker.ClusterVO;
+import com.rocketmq.studio.common.domain.enums.ClusterStatus;
+import com.rocketmq.studio.common.domain.enums.ClusterType;
+import com.rocketmq.studio.common.exception.BusinessException;
+import com.rocketmq.studio.ops.ai.AiToolVO;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.core.io.ByteArrayResource;
+import org.springframework.core.io.ClassPathResource;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.util.List;
+import java.util.Map;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
+
+class ToolGatewayServiceTest {
+
+    private ToolCatalog catalog;
+    private ClusterService clusterService;
+    private CapabilityResolver capabilityResolver;
+    private ClusterListToolHandler clusterListHandler;
+    private CapabilitiesToolHandler capabilitiesHandler;
+    private ToolGatewayService gateway;
+
+    @BeforeEach
+    void setUp() {
+        catalog = canonicalCatalog();
+        clusterService = mock(ClusterService.class);
+        capabilityResolver = new CapabilityResolver(clusterService);
+        clusterListHandler = new ClusterListToolHandler(clusterService);
+        capabilitiesHandler = new CapabilitiesToolHandler(clusterService, 
capabilityResolver);
+        gateway = gateway(catalog, clusterListHandler, capabilitiesHandler);
+    }
+
+    @Test
+    void discoveryWithoutClusterOnlyExposesClusterList() {
+        assertThat(gateway.discover(null))
+                .extracting(AiToolVO::getName)
+                .containsExactly("rmq.cluster.list");
+        verifyNoInteractions(clusterService);
+    }
+
+    @Test
+    void discoveryWithClusterExposesRegisteredSupportedTools() {
+        
when(clusterService.getCluster("cluster-v5")).thenReturn(cluster(ClusterType.V5_PROXY_CLUSTER));
+
+        assertThat(gateway.discover("cluster-v5"))
+                .extracting(AiToolVO::getName)
+                .containsExactly("rmq.cluster.list", "rmq.capabilities");
+    }
+
+    @Test
+    void executesClusterListThroughADataMinimizingProjection() {
+        ClusterVO cluster = cluster(ClusterType.V5_PROXY_CLUSTER);
+        cluster.setEndpoint("do-not-expose.example:9876");
+        when(clusterService.listClusters()).thenReturn(List.of(cluster));
+
+        Object output = gateway.execute("rmq.cluster.list", Map.of());
+
+        assertThat(output).isEqualTo(List.of(Map.of(
+                "id", "cluster-v5",
+                "name", "test",
+                "type", "V5_PROXY_CLUSTER",
+                "status", "healthy",
+                "version", "5.2.0")));
+        assertThat(output.toString()).doesNotContain("do-not-expose");
+    }
+
+    @Test
+    void executesCapabilitiesWithAStableSortedCapabilityList() {
+        
when(clusterService.getCluster("cluster-v5")).thenReturn(cluster(ClusterType.V5_PROXY_CLUSTER));
+
+        Object output = gateway.execute(
+                "rmq.capabilities", Map.of("cluster", "cluster-v5"));
+
+        assertThat(output).isEqualTo(Map.of(
+                "cluster", "cluster-v5",
+                "type", "V5_PROXY_CLUSTER",
+                "version", "5.2.0",
+                "capabilities", List.of(
+                        "ACL_V2",
+                        "CLUSTER_PROXY",
+                        "GRPC",
+                        "LITE_TOPIC",
+                        "POP",
+                        "REMOTING",
+                        "ROCKETMQ_5")));
+    }
+
+    @Test
+    void resolvesCapabilitiesForEveryExistingClusterType() {
+        when(clusterService.getCluster("v4")).thenReturn(cluster("v4", 
ClusterType.V4_DIRECT));
+        when(clusterService.getCluster("v5-local"))
+                .thenReturn(cluster("v5-local", ClusterType.V5_PROXY_LOCAL));
+        when(clusterService.getCluster("v5-cluster"))
+                .thenReturn(cluster("v5-cluster", 
ClusterType.V5_PROXY_CLUSTER));
+
+        assertThat(capabilityResolver.resolve("v4"))
+                .containsExactly("REMOTING", "ROCKETMQ_4");
+        assertThat(capabilityResolver.resolve("v5-local"))
+                .containsExactly(
+                        "ACL_V2",
+                        "GRPC",
+                        "LITE_TOPIC",
+                        "LOCAL_PROXY",
+                        "POP",
+                        "REMOTING",
+                        "ROCKETMQ_5");
+        assertThat(capabilityResolver.resolve("v5-cluster"))
+                .containsExactly(
+                        "ACL_V2",
+                        "CLUSTER_PROXY",
+                        "GRPC",
+                        "LITE_TOPIC",
+                        "POP",
+                        "REMOTING",
+                        "ROCKETMQ_5");
+    }
+
+    @Test
+    void rejectsClusterWithMissingType() {
+        
when(clusterService.getCluster("unknown")).thenReturn(cluster("unknown", null));
+
+        assertThatThrownBy(() -> capabilityResolver.resolve("unknown"))
+                .isInstanceOf(BusinessException.class)
+                .hasMessageContaining("Cluster type is unavailable");
+    }
+
+    @Test
+    void rejectsCapabilitiesExecutionWhenClusterTypeIsMissing() {
+        
when(clusterService.getCluster("unknown")).thenReturn(cluster("unknown", null));
+
+        assertThatThrownBy(() -> gateway.execute(
+                "rmq.capabilities", Map.of("cluster", "unknown")))
+                .isInstanceOf(BusinessException.class)
+                .hasMessageContaining("Cluster type is unavailable");
+    }
+
+    @Test
+    void rejectsClusterListEntriesWithMissingTypeUsingAStableError() {
+        
when(clusterService.listClusters()).thenReturn(List.of(cluster("unknown", 
null)));
+
+        assertThatThrownBy(() -> gateway.execute("rmq.cluster.list", Map.of()))
+                .isInstanceOf(IllegalStateException.class)
+                .hasMessageContaining("Cluster type is unavailable");
+    }
+
+    @Test
+    void rejectsCapabilitiesWithoutRequiredClusterBeforeHandlerRuns() {
+        assertThatThrownBy(() -> gateway.execute("rmq.capabilities", Map.of()))
+                .isInstanceOf(BusinessException.class)
+                .hasMessageContaining("input validation failed");
+        verifyNoInteractions(clusterService);
+    }
+
+    @Test
+    void rejectsUnexpectedInputPropertiesBeforeHandlerRuns() {
+        assertThatThrownBy(() -> gateway.execute(
+                "rmq.cluster.list", Map.of("endpoint", "attacker.example")))
+                .isInstanceOf(BusinessException.class)
+                .hasMessageContaining("input validation failed");
+        verifyNoInteractions(clusterService);
+    }
+
+    @Test
+    void rejectsUnknownTools() {
+        assertThatThrownBy(() -> gateway.execute("rmq.unknown", Map.of()))
+                .isInstanceOf(BusinessException.class)
+                .hasMessageContaining("Tool not found");
+    }
+
+    @Test
+    void refusesNonL1CatalogEntriesEvenWhenAHandlerIsRegistered() throws 
IOException {
+        String yaml = canonicalCatalogText().replaceFirst("riskLevel: L1", 
"riskLevel: L2");
+        ToolCatalog l2Catalog = ToolCatalog.load(
+                new ByteArrayResource(yaml.getBytes(StandardCharsets.UTF_8)),
+                new ClassPathResource("tool-catalog/rmq-tools.schema.json"));
+        ToolGatewayService l2Gateway = gateway(l2Catalog, clusterListHandler, 
capabilitiesHandler);
+
+        assertThatThrownBy(() -> l2Gateway.execute("rmq.cluster.list", 
Map.of()))
+                .isInstanceOf(BusinessException.class)
+                .hasMessageContaining("only L1 tools are enabled");
+        verifyNoInteractions(clusterService);
+    }
+
+    @Test
+    void failsStartupForDuplicateHandlerNames() {
+        assertThatThrownBy(() -> gateway(
+                catalog, clusterListHandler, clusterListHandler, 
capabilitiesHandler))
+                .isInstanceOf(IllegalStateException.class)
+                .hasMessageContaining("duplicate handler");
+    }
+
+    @Test
+    void failsStartupWhenCatalogAndHandlersDoNotMatch() {
+        assertThatThrownBy(() -> gateway(catalog, clusterListHandler))
+                .isInstanceOf(IllegalStateException.class)
+                .hasMessageContaining("missing handler");
+    }
+
+    @Test
+    void failsStartupWhenToolSchemaContainsAnUnresolvedReference() throws 
IOException {
+        String yaml = canonicalCatalogText().replace(
+                """
+                            inputSchema:
+                              type: object
+                              additionalProperties: false
+                        """,
+                """
+                            inputSchema:
+                              $ref: '#/missing'
+                        """);
+        ToolCatalog invalidCatalog = ToolCatalog.load(
+                new ByteArrayResource(yaml.getBytes(StandardCharsets.UTF_8)),
+                new ClassPathResource("tool-catalog/rmq-tools.schema.json"));
+
+        assertThatThrownBy(() -> gateway(
+                invalidCatalog, clusterListHandler, capabilitiesHandler))
+                .isInstanceOf(IllegalStateException.class)
+                .hasMessageContaining("input schema")
+                .hasMessageContaining("rmq.cluster.list");
+    }
+
+    @Test
+    void failsStartupWhenToolSchemaViolatesTheJsonSchemaMetaSchema() throws 
IOException {
+        String yaml = canonicalCatalogText().replace(
+                """
+                            inputSchema:
+                              type: object
+                        """,
+                """
+                            inputSchema:
+                              type: unsupported
+                        """);
+        ToolCatalog invalidCatalog = ToolCatalog.load(
+                new ByteArrayResource(yaml.getBytes(StandardCharsets.UTF_8)),
+                new ClassPathResource("tool-catalog/rmq-tools.schema.json"));
+
+        assertThatThrownBy(() -> gateway(
+                invalidCatalog, clusterListHandler, capabilitiesHandler))
+                .isInstanceOf(IllegalStateException.class)
+                .hasMessageContaining("input schema")
+                .hasMessageContaining("rmq.cluster.list");
+    }
+
+    @Test
+    void validatesHandlerOutputAgainstTheCatalog() {
+        ToolHandler invalidClusterListHandler = new ToolHandler() {
+            @Override
+            public String name() {
+                return "rmq.cluster.list";
+            }
+
+            @Override
+            public Object execute(Map<String, Object> input) {
+                return Map.of("bad", "shape");
+            }
+        };
+        ToolGatewayService invalidGateway = gateway(
+                catalog, invalidClusterListHandler, capabilitiesHandler);
+
+        assertThatThrownBy(() -> invalidGateway.execute("rmq.cluster.list", 
Map.of()))
+                .isInstanceOf(IllegalStateException.class)
+                .hasMessageContaining("output validation failed");
+    }
+
+    private ToolGatewayService gateway(ToolCatalog toolCatalog, ToolHandler... 
handlers) {
+        return new ToolGatewayService(
+                toolCatalog,
+                capabilityResolver,
+                new ObjectMapper(),
+                List.of(handlers));
+    }
+
+    private static ToolCatalog canonicalCatalog() {
+        return ToolCatalog.load(
+                new ClassPathResource("tool-catalog/rmq-tools.yaml"),
+                new ClassPathResource("tool-catalog/rmq-tools.schema.json"));
+    }
+
+    private static String canonicalCatalogText() throws IOException {
+        return new ClassPathResource("tool-catalog/rmq-tools.yaml")
+                .getContentAsString(StandardCharsets.UTF_8);
+    }
+
+    private static ClusterVO cluster(ClusterType type) {
+        return cluster("cluster-v5", type);
+    }
+
+    private static ClusterVO cluster(String id, ClusterType type) {
+        ClusterVO cluster = ClusterVO.builder()
+                .name("test")
+                .type(type)
+                .status(ClusterStatus.healthy)
+                .version("5.2.0")
+                .build();
+        cluster.setId(id);
+        return cluster;
+    }
+}

Reply via email to