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
The following commit(s) were added to refs/heads/rocketmq-studio by this push:
new 5158c0107 feat(server): address Studio instances by instanceId in the
tool layer (#4308)
5158c0107 is described below
commit 5158c0107522ea13486cbe4e4494ccedfe78aa37
Author: lizhimins <[email protected]>
AuthorDate: Tue Sep 15 11:11:16 2026 +0800
feat(server): address Studio instances by instanceId in the tool layer
(#4308)
The instance identifier becomes an explicit required parameter instead of
an ambient target: the MCP header is x-rmq-instance-id,
ToolExecutionContext and McpAuthentication expose instanceId(), the
executor requires instanceId in the tool arguments and cross-checks it
against the authenticated target, and the eleven platform-level tools
skip both checks. Tool discovery keeps returning an empty list for a
blank target so the console can still list the global tools.
---
.../apache/rocketmq/studio/StudioApplication.java | 2 -
.../rocketmq/studio/common/config/CacheConfig.java | 10 +-
.../rocketmq/studio/ops/ai/McpServerRegistry.java | 2 +-
.../rocketmq/studio/ops/ai/ToolController.java | 26 +-
.../studio/ops/ai/auth/McpAuthentication.java | 4 +-
.../studio/ops/ai/auth/McpAuthenticator.java | 24 +-
.../studio/ops/ai/tool/filter/ToolAuditFilter.java | 2 +-
.../ops/ai/tool/filter/ToolCapabilityFilter.java | 2 +-
.../ops/ai/tool/filter/ToolMutationFilter.java | 4 +-
.../ops/ai/tool/service/ToolDiscoveryService.java | 10 +-
.../ops/ai/tool/service/ToolExecutionService.java | 43 ++-
.../ops/ai/tool/service/ToolTokenService.java | 4 +-
.../rocketmq/studio/StudioApplicationTest.java | 5 +-
.../studio/auth/AuthCorsIntegrationTest.java | 8 +-
.../rocketmq/studio/auth/AuthInterceptorTest.java | 2 +-
.../studio/common/config/CorsConfigTest.java | 5 +-
.../rocketmq/studio/ops/ai/ToolControllerTest.java | 79 ++++--
.../ai/auth/McpCredentialAuthenticationTest.java | 60 ++---
.../studio/ops/ai/mcp/McpToolRegistrarTest.java | 16 +-
.../ops/ai/tool/filter/ToolMutationFilterTest.java | 8 +-
.../ai/tool/service/ToolDiscoveryServiceTest.java | 29 ++-
.../tool/service/ToolExecutorInvocationTest.java | 36 +--
.../ai/tool/service/ToolInstanceRoutingTest.java | 24 +-
.../tool/service/ToolOutputSchemaContractTest.java | 290 ++++++++++++++++++---
.../tool/service/ToolTokenConfigurationTest.java | 16 +-
.../ops/ai/tool/service/ToolTokenServiceTest.java | 22 +-
.../ops/alert/AlertSilenceControllerTest.java | 3 +-
web/src/api/ai.test.ts | 12 +-
web/src/api/ai.ts | 7 +-
web/src/pages/ai/__tests__/AiPage.test.tsx | 32 ++-
web/src/pages/ai/index.tsx | 6 +-
31 files changed, 550 insertions(+), 243 deletions(-)
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/StudioApplication.java
b/server/src/main/java/org/apache/rocketmq/studio/StudioApplication.java
index 1ea2baee9..38a537af1 100644
--- a/server/src/main/java/org/apache/rocketmq/studio/StudioApplication.java
+++ b/server/src/main/java/org/apache/rocketmq/studio/StudioApplication.java
@@ -19,12 +19,10 @@ package org.apache.rocketmq.studio;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
-import org.springframework.cache.annotation.EnableCaching;
import org.springframework.scheduling.annotation.EnableScheduling;
@SpringBootApplication
@EnableScheduling
-@EnableCaching
public class StudioApplication {
public static void main(String[] args) {
SpringApplication.run(StudioApplication.class, args);
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/common/config/CacheConfig.java
b/server/src/main/java/org/apache/rocketmq/studio/common/config/CacheConfig.java
index 3fa534a07..cfb9f99d3 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/common/config/CacheConfig.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/common/config/CacheConfig.java
@@ -17,13 +17,18 @@
package org.apache.rocketmq.studio.common.config;
import org.springframework.cache.CacheManager;
+import org.springframework.cache.annotation.EnableCaching;
import org.springframework.cache.concurrent.ConcurrentMapCacheManager;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
- * Declares the {@link CacheManager} backing the {@code @Cacheable} methods
enabled by
- * {@code @EnableCaching} on the application class.
+ * Enables caching and declares the {@link CacheManager} backing the {@code
@Cacheable} methods.
+ * <p>
+ * {@code @EnableCaching} lives here rather than on {@code StudioApplication}
so that
+ * {@code @WebMvcTest} slices (which do not load this {@code @Configuration})
never activate the
+ * caching infrastructure and therefore do not require a {@code CacheManager}
bean to start their
+ * application context. The full application still picks up this class via
component scanning.
* <p>
* {@code @EnableCaching} on its own leaves the manager to Spring Boot's cache
* auto-configuration. In this application that did not yield a manager able
to serve the
@@ -38,6 +43,7 @@ import org.springframework.context.annotation.Configuration;
* reintroduce the same failure. Caches in use today: {@code data-sources}.
*/
@Configuration
+@EnableCaching
public class CacheConfig {
@Bean
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/ops/ai/McpServerRegistry.java
b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/McpServerRegistry.java
index cf947d5c7..56cca770f 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/ops/ai/McpServerRegistry.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/McpServerRegistry.java
@@ -24,7 +24,7 @@ public interface McpServerRegistry {
List<AiToolVO> listTools();
- List<AiToolVO> listTools(String clusterId);
+ List<AiToolVO> listTools(String instanceId);
Object execute(String name, Map<String, Object> input);
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/ops/ai/ToolController.java
b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/ToolController.java
index 9970b49ce..9c433a1a8 100644
--- a/server/src/main/java/org/apache/rocketmq/studio/ops/ai/ToolController.java
+++ b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/ToolController.java
@@ -20,6 +20,7 @@ import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.rocketmq.studio.common.domain.Result;
+import org.apache.rocketmq.studio.ops.ai.tool.catalog.ToolCatalog;
import org.apache.rocketmq.studio.ops.ai.tool.service.ToolDiscoveryService;
import org.apache.rocketmq.studio.ops.ai.tool.service.ToolExecutionService;
import org.springframework.web.bind.annotation.GetMapping;
@@ -31,6 +32,7 @@ import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.Collections;
+import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@@ -46,20 +48,34 @@ public class ToolController {
@GetMapping
public Result<List<AiToolVO>> listTools(
- @RequestParam String cluster) {
- return Result.ok(toolDiscoveryService.listTools(cluster));
+ @RequestParam(required = false) String instanceId,
+ @RequestParam(required = false) String cluster) {
+ return Result.ok(toolDiscoveryService.listTools(instanceId != null ?
instanceId : cluster));
}
@PostMapping("/{name}/execute")
public Result<Object> executeTool(
@PathVariable String name,
+ @RequestParam String instanceId,
@RequestBody(required = false) Map<String, Object> input) {
- Map<String, Object> normalizedInput = input == null
- ? Collections.emptyMap()
- : input;
+ Map<String, Object> normalizedInput = withTargetInstance(name, input,
instanceId);
AiPayloadGuard.validateToolInvocation(name, normalizedInput,
objectMapper);
log.info("Executing registered AI tool: {}", name);
return Result.ok(toolExecutor.execute(name, normalizedInput));
}
+ /**
+ * The executor reads the Studio instance target from the tool arguments,
so the REST query
+ * parameter is copied in. Platform-level tools are addressed by a
physical {@code clusterName}
+ * and their input schemas reject unknown arguments, so their payload
stays untouched.
+ */
+ private static Map<String, Object> withTargetInstance(
+ String name, Map<String, Object> input, String instanceId) {
+ Map<String, Object> normalized = new LinkedHashMap<>(input == null ?
Map.of() : input);
+ if (!ToolCatalog.isInstanceIdExempt(name)) {
+ normalized.put(ToolCatalog.INSTANCE_ID_FIELD, instanceId);
+ }
+ return Collections.unmodifiableMap(normalized);
+ }
+
}
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/ops/ai/auth/McpAuthentication.java
b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/auth/McpAuthentication.java
index b5911beb6..7f3dedaa3 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/ops/ai/auth/McpAuthentication.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/auth/McpAuthentication.java
@@ -17,13 +17,13 @@
package org.apache.rocketmq.studio.ops.ai.auth;
public record McpAuthentication(
- String cluster,
+ String instanceId,
String principal) {
public static final String ATTRIBUTE = McpAuthentication.class.getName();
public McpAuthentication {
- requireText(cluster, "cluster");
+ requireText(instanceId, "instanceId");
requireText(principal, "principal");
}
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/ops/ai/auth/McpAuthenticator.java
b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/auth/McpAuthenticator.java
index 5d65ccb62..bddc6f25c 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/ops/ai/auth/McpAuthenticator.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/auth/McpAuthenticator.java
@@ -43,8 +43,8 @@ import java.util.regex.Pattern;
public class McpAuthenticator {
static final String ALGORITHM = "RMQ-HMAC-SHA256";
- static final String HEADER_CLUSTER = "X-RMQ-Cluster";
- static final String HEADER_TIMESTAMP = "X-RMQ-Timestamp";
+ static final String HEADER_INSTANCE = "x-rmq-instance-id";
+ static final String HEADER_TIMESTAMP = "x-rmq-timestamp";
static final String AUTHENTICATION_FAILED_MESSAGE = "MCP authentication
failed.";
static final Duration MAX_CLOCK_SKEW = Duration.ofMinutes(5);
private static final Pattern AUTHORIZATION = Pattern.compile(
@@ -75,13 +75,13 @@ public class McpAuthenticator {
public McpAuthentication authenticate(HttpServletRequest request) {
Authorization authorization =
authorization(request.getHeader(HttpHeaders.AUTHORIZATION));
- String cluster = requiredHeader(request, HEADER_CLUSTER);
+ String instanceId = requiredHeader(request, HEADER_INSTANCE);
String timestamp = requiredHeader(request, HEADER_TIMESTAMP);
verifyTimestamp(timestamp);
- InstanceVO instance = resolveInstance(cluster);
+ InstanceVO instance = resolveInstance(instanceId);
Credential credential = resolveCredential(instance);
- verifySignature(request, authorization, credential, cluster,
timestamp);
- return new McpAuthentication(cluster, authorization.accessKey());
+ verifySignature(request, authorization, credential, instanceId,
timestamp);
+ return new McpAuthentication(instanceId, authorization.accessKey());
}
private Authorization authorization(String value) {
@@ -115,9 +115,9 @@ public class McpAuthenticator {
return value;
}
- private InstanceVO resolveInstance(String cluster) {
+ private InstanceVO resolveInstance(String instanceId) {
try {
- return
instanceResolver.findByName(cluster).orElseThrow(McpAuthenticator::authenticationFailed);
+ return
instanceResolver.findByName(instanceId).orElseThrow(McpAuthenticator::authenticationFailed);
} catch (BusinessException exception) {
if (exception.getCode() == 422) {
throw authenticationFailed(exception);
@@ -150,12 +150,12 @@ public class McpAuthenticator {
}
private static void verifySignature(HttpServletRequest request,
Authorization authorization, Credential credential,
- String cluster, String timestamp) {
+ String instanceId, String timestamp) {
if (!authorization.accessKey().equals(credential.accessKey())) {
throw authenticationFailed();
}
byte[] expected = hmac(credential.secretKey(),
- canonicalRequest(authorization.accessKey(), cluster, timestamp,
+ canonicalRequest(authorization.accessKey(), instanceId,
timestamp,
request.getMethod(), requestTarget(request)));
if (!MessageDigest.isEqual(expected, authorization.signature())) {
throw authenticationFailed();
@@ -193,14 +193,14 @@ public class McpAuthenticator {
static String canonicalRequest(
String accessKey,
- String cluster,
+ String instanceId,
String timestamp,
String method,
String requestTarget) {
return String.join("\n",
ALGORITHM,
accessKey,
- cluster,
+ instanceId,
timestamp,
method,
requestTarget);
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/ops/ai/tool/filter/ToolAuditFilter.java
b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/tool/filter/ToolAuditFilter.java
index ed307f1b0..e80647ca3 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/ops/ai/tool/filter/ToolAuditFilter.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/tool/filter/ToolAuditFilter.java
@@ -51,7 +51,7 @@ public class ToolAuditFilter implements ToolExecutionFilter {
auditService.record(context.operationType(),
context.resourceType(),
context.definition().name(),
- context.cluster(),
+ context.instanceId(),
errorMessage,
result
);
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/ops/ai/tool/filter/ToolCapabilityFilter.java
b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/tool/filter/ToolCapabilityFilter.java
index dd0b45e4b..198eb8910 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/ops/ai/tool/filter/ToolCapabilityFilter.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/tool/filter/ToolCapabilityFilter.java
@@ -45,7 +45,7 @@ public class ToolCapabilityFilter implements
ToolExecutionFilter {
return chain.proceed(invocation);
}
- Set<String> capabilities =
capabilityResolver.resolve(context.cluster());
+ Set<String> capabilities =
capabilityResolver.resolve(context.instanceId());
if (!capabilities.containsAll(definition.requiredCapabilities())) {
throw
ToolError.TOOL_CAPABILITY_UNSUPPORTED.exception(definition.name());
}
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/ops/ai/tool/filter/ToolMutationFilter.java
b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/tool/filter/ToolMutationFilter.java
index 12013ac1b..7c60d2887 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/ops/ai/tool/filter/ToolMutationFilter.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/tool/filter/ToolMutationFilter.java
@@ -66,11 +66,11 @@ public class ToolMutationFilter implements
ToolExecutionFilter {
}
if (context.dryRun()) {
String token = tokenService.issue(context);
- return new MutationOutput<>(MutationOutput.Status.PLANNED,
context.cluster(), plan, token, null);
+ return new MutationOutput<>(MutationOutput.Status.PLANNED,
context.instanceId(), plan, token, null);
}
Object result = chain.proceed(invocation);
- return new MutationOutput<>(MutationOutput.Status.EXECUTED,
context.cluster(), plan, null, result);
+ return new MutationOutput<>(MutationOutput.Status.EXECUTED,
context.instanceId(), plan, null, result);
}
private void verifyL3Requirements(ToolExecutionContext context) {
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/ops/ai/tool/service/ToolDiscoveryService.java
b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/tool/service/ToolDiscoveryService.java
index 576a4f545..16b092d8d 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/ops/ai/tool/service/ToolDiscoveryService.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/tool/service/ToolDiscoveryService.java
@@ -44,12 +44,12 @@ public class ToolDiscoveryService {
this.instanceResolver = instanceResolver;
}
- public List<AiToolVO> listTools(String cluster) {
- if (cluster == null || cluster.isBlank()) {
- throw ToolError.TOOL_CLUSTER_REQUIRED.exception();
+ public List<AiToolVO> listTools(String instanceId) {
+ if (instanceId == null || instanceId.isBlank()) {
+ return List.of();
}
- InstanceVO instance = instanceResolver.findByName(cluster)
- .orElseThrow(() ->
ToolError.INSTANCE_NOT_FOUND.exception(cluster));
+ InstanceVO instance = instanceResolver.findByName(instanceId)
+ .orElseThrow(() ->
ToolError.INSTANCE_NOT_FOUND.exception(instanceId));
Set<String> capabilities = capabilityResolver.resolve(instance);
return catalog.list().stream()
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/ops/ai/tool/service/ToolExecutionService.java
b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/tool/service/ToolExecutionService.java
index 78aeef68c..97ba10f5c 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/ops/ai/tool/service/ToolExecutionService.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/tool/service/ToolExecutionService.java
@@ -94,21 +94,10 @@ public class ToolExecutionService {
private Object executeInternal(String name, Map<String, Object> input,
McpAuthentication authentication) {
try {
ToolDefinition definition = catalog.getDefinition(name);
- Object value = input == null ? null : input.get("cluster");
- if (!(value instanceof String cluster) || cluster.isBlank()) {
- throw ToolError.TOOL_CLUSTER_REQUIRED.exception();
- }
- if (authentication != null) {
- if (!cluster.equals(authentication.cluster())) {
- throw ToolError.TOOL_TARGET_MISMATCH.exception();
- }
- } else {
- instanceResolver.findByName(cluster)
- .orElseThrow(() ->
ToolError.INSTANCE_NOT_FOUND.exception(cluster));
- }
+ String instanceId = resolveTargetInstance(definition, input,
authentication);
String caller = authentication == null
? AuthenticatedUserContext.currentUsernameOrSystem() :
authentication.principal();
- ToolExecutionContext context = ToolExecutionContext.of(cluster,
definition, input, caller);
+ ToolExecutionContext context = ToolExecutionContext.of(instanceId,
definition, input, caller);
ToolHandler<?, ?> handler = this.handlers.get(name);
return filterChain.execute(new ToolInvocation(context, handler));
@@ -120,4 +109,32 @@ public class ToolExecutionService {
throw internal;
}
}
+
+ /**
+ * Reads the Studio instance target from the tool arguments.
Platform-level tools are addressed
+ * by a physical {@code clusterName} instead, so they skip both the
mandatory {@code instanceId}
+ * check and the authenticated-target cross-check.
+ */
+ private String resolveTargetInstance(
+ ToolDefinition definition, Map<String, Object> input,
McpAuthentication authentication) {
+ Object value = input == null ? null :
input.get(ToolCatalog.INSTANCE_ID_FIELD);
+ if (!(value instanceof String instanceId) || instanceId.isBlank()) {
+ if (ToolCatalog.isInstanceIdExempt(definition.name())) {
+ return null;
+ }
+ throw
ToolError.TOOL_INSTANCE_REQUIRED.exception(definition.name());
+ }
+ if (ToolCatalog.isInstanceIdExempt(definition.name())) {
+ return instanceId;
+ }
+ if (authentication != null) {
+ if (!instanceId.equals(authentication.instanceId())) {
+ throw ToolError.TOOL_TARGET_MISMATCH.exception();
+ }
+ } else {
+ instanceResolver.findByName(instanceId)
+ .orElseThrow(() ->
ToolError.INSTANCE_NOT_FOUND.exception(instanceId));
+ }
+ return instanceId;
+ }
}
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/ops/ai/tool/service/ToolTokenService.java
b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/tool/service/ToolTokenService.java
index 6e2aacbea..02a239018 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/ops/ai/tool/service/ToolTokenService.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/tool/service/ToolTokenService.java
@@ -124,7 +124,7 @@ public class ToolTokenService {
expiresAt,
context.definition().name(),
subjectBinding(context),
- context.cluster(),
+ context.instanceId(),
canonicalInput(context.businessInput()));
return objectMapper.writeValueAsBytes(payload);
} catch (JsonProcessingException e) {
@@ -136,7 +136,7 @@ public class ToolTokenService {
if (context.principal() != null && !context.principal().isBlank()) {
return "principal:" + context.principal();
}
- return "instance:" + context.cluster();
+ return "instance:" + context.instanceId();
}
private static Map<String, Object> canonicalInput(Map<?, ?> input) {
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/StudioApplicationTest.java
b/server/src/test/java/org/apache/rocketmq/studio/StudioApplicationTest.java
index 9a5b7a428..f22c26da1 100644
--- a/server/src/test/java/org/apache/rocketmq/studio/StudioApplicationTest.java
+++ b/server/src/test/java/org/apache/rocketmq/studio/StudioApplicationTest.java
@@ -16,8 +16,6 @@
*/
package org.apache.rocketmq.studio;
-import org.apache.rocketmq.studio.ops.ai.tool.core.ToolExecutionException;
-import static org.assertj.core.api.Assertions.assertThatThrownBy;
import org.apache.rocketmq.studio.ops.ai.tool.catalog.ToolCatalog;
import org.apache.rocketmq.studio.ops.ai.tool.service.ToolDiscoveryService;
import org.apache.rocketmq.studio.common.domain.enums.InstanceType;
@@ -75,8 +73,7 @@ class StudioApplicationTest {
@Test
void applicationContextLoadsWithInitializedDevSchema() throws Exception {
assertThat(toolCatalog.list()).isNotEmpty();
- assertThatThrownBy(() -> toolDiscoveryService.listTools(null))
- .isInstanceOf(ToolExecutionException.class);
+ assertThat(toolDiscoveryService.listTools(null)).isEmpty();
assertThat(instanceMapper.selectList(null)).isEmpty();
mockMvc.perform(get("/api/instances"))
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/auth/AuthCorsIntegrationTest.java
b/server/src/test/java/org/apache/rocketmq/studio/auth/AuthCorsIntegrationTest.java
index c5760dd3f..d5ec1897f 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/auth/AuthCorsIntegrationTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/auth/AuthCorsIntegrationTest.java
@@ -34,6 +34,8 @@ import org.springframework.context.annotation.Import;
import org.springframework.http.HttpHeaders;
import org.springframework.test.web.servlet.MockMvc;
+import java.util.Optional;
+
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.verifyNoMoreInteractions;
@@ -112,13 +114,13 @@ class AuthCorsIntegrationTest {
.header(HttpHeaders.ORIGIN, FRONTEND_ORIGIN))
.andExpect(status().isUnauthorized());
- verify(authService).isAuthenticated(null);
+ verify(authService).getAuthenticatedUser(null);
}
@Test
void shouldRejectNonAdminMutationBeforeControllerExecution() throws
Exception {
String authorization = "Bearer reader-token";
- when(authService.isAuthenticated(authorization)).thenReturn(true);
- when(authService.isAdmin(authorization)).thenReturn(false);
+
when(authService.getAuthenticatedUser(authorization)).thenReturn(Optional.of(
+
LoginVO.UserInfo.builder().userId(2L).username("reader").admin(false).build()));
mockMvc.perform(post("/api/instances/create")
.header(HttpHeaders.AUTHORIZATION, authorization)
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/auth/AuthInterceptorTest.java
b/server/src/test/java/org/apache/rocketmq/studio/auth/AuthInterceptorTest.java
index c25a2d110..042e15ce5 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/auth/AuthInterceptorTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/auth/AuthInterceptorTest.java
@@ -436,7 +436,7 @@ class AuthInterceptorTest {
void shouldAllowReaderSafeAiToolExecutionForNonAdminUser() throws
Exception {
TestSession session = login(false);
MockHttpServletRequest request = authenticatedRequest(
- "POST", "/api/ai/tools/rmq.capabilities/execute",
session.token());
+ "POST", "/api/ai/tools/rmq.instance.capabilities/execute",
session.token());
boolean allowed = session.interceptor().preHandle(
request, new MockHttpServletResponse(), new Object());
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/common/config/CorsConfigTest.java
b/server/src/test/java/org/apache/rocketmq/studio/common/config/CorsConfigTest.java
index 0f65a4e8d..c21c45453 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/common/config/CorsConfigTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/common/config/CorsConfigTest.java
@@ -17,6 +17,7 @@
package org.apache.rocketmq.studio.common.config;
+import org.apache.rocketmq.studio.WebMvcAuthTestSupport;
import org.apache.rocketmq.studio.instance.dlq.DLQController;
import org.apache.rocketmq.studio.instance.dlq.DLQExcelExportResultVO;
import org.apache.rocketmq.studio.instance.dlq.DLQExportResultVO;
@@ -56,8 +57,8 @@ import static
org.springframework.test.web.servlet.result.MockMvcResultMatchers.
*/
@WebMvcTest(value = DLQController.class, properties =
"studio.cors.allowed-origins=http://localhost:5173")
@AutoConfigureMockMvc(addFilters = false)
-@Import(CorsConfig.class)
-class CorsConfigTest {
+@Import({CorsConfig.class, LegacyJackson2Config.class})
+class CorsConfigTest extends WebMvcAuthTestSupport {
private static final String FRONTEND_ORIGIN = "http://localhost:5173";
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/ops/ai/ToolControllerTest.java
b/server/src/test/java/org/apache/rocketmq/studio/ops/ai/ToolControllerTest.java
index 6e47ce806..8c1da9519 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/ops/ai/ToolControllerTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/ops/ai/ToolControllerTest.java
@@ -16,7 +16,6 @@
*/
package org.apache.rocketmq.studio.ops.ai;
-import static org.mockito.Mockito.verifyNoInteractions;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.apache.rocketmq.studio.ops.ai.auth.McpAuthentication;
import org.apache.rocketmq.studio.ops.ai.tool.service.ToolDiscoveryService;
@@ -32,6 +31,7 @@ import java.util.Collections;
import java.util.List;
import java.util.Map;
+import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
@@ -66,7 +66,7 @@ class ToolControllerTest {
}
@Test
- void listToolsReturnsCatalogEntries() throws Exception {
+ void listToolsReturnsCatalogEntriesTest() throws Exception {
AiToolVO tool = AiToolVO.builder()
.name("rmq.cluster.list")
.version("1.0.0")
@@ -80,19 +80,19 @@ class ToolControllerTest {
.outputSchema(Map.of("type", "array"))
.viewHint("table")
.build();
-
when(toolDiscoveryService.listTools("cluster-001")).thenReturn(List.of(tool));
+
when(toolDiscoveryService.listTools("instance-id")).thenReturn(List.of(tool));
- mockMvc.perform(get("/api/ai/tools").queryParam("cluster",
"cluster-001"))
+ mockMvc.perform(get("/api/ai/tools").queryParam("instanceId",
"instance-id"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data[0].name").value("rmq.cluster.list"))
.andExpect(jsonPath("$.data[0].version").value("1.0.0"));
}
@Test
- void listToolsDelegatesTheSelectedTarget() throws Exception {
+ void listToolsDelegatesTheSelectedTargetTest() throws Exception {
when(toolDiscoveryService.listTools("cluster-001"))
.thenReturn(Collections.emptyList());
- when(toolDiscoveryService.listTools("cluster-002"))
+ when(toolDiscoveryService.listTools("instance-id"))
.thenReturn(Collections.emptyList());
mockMvc.perform(get("/api/ai/tools").queryParam("cluster",
"cluster-001"))
@@ -103,38 +103,68 @@ class ToolControllerTest {
.andExpect(status().isOk());
verify(toolDiscoveryService).listTools("cluster-001");
- verify(toolDiscoveryService).listTools("cluster-002");
+ verify(toolDiscoveryService).listTools("instance-id");
+ }
+
+ /** The global-tool scope of the AI page lists platform tools without
binding an Instance. */
+ @Test
+ void listToolsAcceptsAMissingTargetForPlatformToolsTest() throws Exception
{
+
when(toolDiscoveryService.listTools(null)).thenReturn(Collections.emptyList());
+
+ mockMvc.perform(get("/api/ai/tools"))
+ .andExpect(status().isOk());
+
+ verify(toolDiscoveryService).listTools(null);
}
@Test
- void executeToolPreservesStructuredInputAndDottedName() throws Exception {
- Map<String, Object> input = Map.of("cluster", "cluster-001");
+ void executeToolPreservesStructuredInputAndDottedNameTest() throws
Exception {
+ Map<String, Object> input = Map.of("instanceId", "instance-id");
Map<String, Object> output = Map.of(
- "cluster", "cluster-001",
+ "instanceId", "instance-id",
"capabilities", List.of("REMOTING"));
- when(toolExecutor.execute("rmq.capabilities", input))
+ when(toolExecutor.execute("rmq.instance.capabilities", input))
.thenReturn(output);
- mockMvc.perform(post("/api/ai/tools/rmq.capabilities/execute")
+ mockMvc.perform(post("/api/ai/tools/rmq.instance.capabilities/execute")
+ .queryParam("instanceId", "instance-id")
.contentType(MediaType.APPLICATION_JSON)
.content("""
- {"cluster":"cluster-001"}
+ {"instanceId":"instance-id"}
"""))
.andExpect(status().isOk())
- .andExpect(jsonPath("$.data.cluster").value("cluster-001"))
+ .andExpect(jsonPath("$.data.instanceId").value("instance-id"))
.andExpect(jsonPath("$.data.capabilities[0]").value("REMOTING"));
- verify(toolExecutor).execute("rmq.capabilities", input);
+ verify(toolExecutor).execute("rmq.instance.capabilities", input);
+ }
+
+ /** Platform tools are addressed by a physical clusterName, so the target
stays out of their payload. */
+ @Test
+ void executeToolKeepsPlatformToolArgumentsUntouchedTest() throws Exception
{
+ Map<String, Object> input = Map.of("clusterName", "DefaultCluster");
+ when(toolExecutor.execute("rmq.broker.list", input))
+ .thenReturn(Map.of("items", Collections.emptyList()));
+
+ mockMvc.perform(post("/api/ai/tools/rmq.broker.list/execute")
+ .queryParam("instanceId", "")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("""
+ {"clusterName":"DefaultCluster"}
+ """))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data.items").isArray());
+
+ verify(toolExecutor).execute("rmq.broker.list", input);
}
@Test
- void callToolDelegatesAuthenticatedInput() throws Exception {
+ void callToolDelegatesAuthenticatedInputTest() throws Exception {
Map<String, Object> input = Map.of(
- "cluster", "cluster-001",
+ "instanceId", "instance-id",
"topic", "order-topic",
"dry_run", true);
- when(toolExecutor.execute(
- "rmq.topic.create", input, authentication))
+ when(toolExecutor.execute("rmq.topic.create", input, authentication))
.thenReturn(Map.of("status", "PLANNED"));
mockMvc.perform(post("/api/mcp/tools/call")
@@ -144,7 +174,7 @@ class ToolControllerTest {
{
"name": "rmq.topic.create",
"arguments": {
- "cluster": "cluster-001",
+ "instanceId": "instance-id",
"topic": "order-topic",
"dry_run": true
}
@@ -154,15 +184,8 @@ class ToolControllerTest {
.andExpect(jsonPath("$.data.status").value("PLANNED"));
verify(toolExecutor).execute(
- eq("rmq.topic.create"),
org.mockito.ArgumentMatchers.argThat(arguments ->
+ eq("rmq.topic.create"), argThat(arguments ->
Boolean.TRUE.equals(arguments.get("dry_run"))),
eq(authentication));
}
- @Test
- void discoveryRequiresClusterEvenWhenLegacyInstanceIdIsProvided() throws
Exception {
- mockMvc.perform(get("/api/ai/tools").queryParam("instanceId",
"instance-a"))
- .andExpect(status().isBadRequest());
- verifyNoInteractions(toolDiscoveryService);
- }
-
}
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/ops/ai/auth/McpCredentialAuthenticationTest.java
b/server/src/test/java/org/apache/rocketmq/studio/ops/ai/auth/McpCredentialAuthenticationTest.java
index e4e5b866a..4634f0af0 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/ops/ai/auth/McpCredentialAuthenticationTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/ops/ai/auth/McpCredentialAuthenticationTest.java
@@ -68,7 +68,7 @@ import static org.mockito.Mockito.when;
class McpCredentialAuthenticationTest {
private static final List<String> ENTRY_POINTS = List.of("/api/mcp",
"/api/mcp/tools/call");
- private static final String CLUSTER = "instance-test";
+ private static final String INSTANCE_ID = "instance-test";
private static final String CLOUD_ACCESS_KEY = "cloud-ak";
private static final String CLOUD_SECRET_KEY = "cloud-test-secret";
private static final String ADMIN_ACCESS_KEY = "admin-ak";
@@ -88,9 +88,9 @@ class McpCredentialAuthenticationTest {
adminResolver = mock(RuntimeAdminClientResolver.class);
cloudCredentialRepository = mock(CloudCredentialRepository.class);
- instance =
InstanceVO.builder().name(CLUSTER).vendor(InstanceVendor.ALIYUN)
+ instance =
InstanceVO.builder().name(INSTANCE_ID).vendor(InstanceVendor.ALIYUN)
.credentialId(7L).adminCredentialRef("admin").build();
-
when(instanceResolver.findByName(CLUSTER)).thenReturn(Optional.of(instance));
+
when(instanceResolver.findByName(INSTANCE_ID)).thenReturn(Optional.of(instance));
cloudCredential = new CloudCredentialVO();
cloudCredential.setVendor(InstanceVendor.ALIYUN);
@@ -114,7 +114,7 @@ class McpCredentialAuthenticationTest {
@ParameterizedTest
@EnumSource(InstanceVendor.class)
- void
authenticatesResolvedInstanceCredentialsAtBothEntryPoints(InstanceVendor
vendor) throws Exception {
+ void
authenticatesResolvedInstanceCredentialsAtBothEntryPointsTest(InstanceVendor
vendor) throws Exception {
instance.setVendor(vendor);
cloudCredential.setVendor(vendor);
boolean apache = vendor == InstanceVendor.APACHE;
@@ -122,7 +122,7 @@ class McpCredentialAuthenticationTest {
String secretKey = apache ? ADMIN_SECRET_KEY : CLOUD_SECRET_KEY;
for (String path : ENTRY_POINTS) {
- MockHttpServletRequest request = signedRequest(path, CLUSTER,
accessKey, secretKey);
+ MockHttpServletRequest request = signedRequest(path, INSTANCE_ID,
accessKey, secretKey);
byte[] body = "body remains
available".getBytes(StandardCharsets.UTF_8);
request.setContent(body);
MockHttpServletResponse response = new MockHttpServletResponse();
@@ -131,7 +131,7 @@ class McpCredentialAuthenticationTest {
filter.doFilter(request, response, (verifiedRequest,
verifiedResponse) -> {
invoked.set(true);
assertThat(verifiedRequest.getAttribute(McpAuthentication.ATTRIBUTE))
- .isEqualTo(new McpAuthentication(CLUSTER, accessKey));
+ .isEqualTo(new McpAuthentication(INSTANCE_ID,
accessKey));
assertThat(AuthenticatedUserContext.currentUsernameOrSystem()).isEqualTo(accessKey);
assertThat(AuthenticatedUserContext.currentUserId()).isNull();
assertThat(AuthenticatedUserContext.currentUserIsAdminOrSystem()).isFalse();
@@ -142,7 +142,7 @@ class McpCredentialAuthenticationTest {
assertThat(invoked).isTrue();
assertIdentityCleared();
}
- verify(instanceResolver,
times(ENTRY_POINTS.size())).findByName(CLUSTER);
+ verify(instanceResolver,
times(ENTRY_POINTS.size())).findByName(INSTANCE_ID);
verifyNoMoreInteractions(instanceResolver);
if (apache) {
verify(adminResolver,
times(ENTRY_POINTS.size())).resolveCredential(instance);
@@ -156,11 +156,11 @@ class McpCredentialAuthenticationTest {
}
@Test
- void clearsIdentityAndPropagatesDownstreamFailure() throws Exception {
+ void clearsIdentityAndPropagatesDownstreamFailureTest() throws Exception {
ServletException failure = new ServletException("Downstream execution
failed");
for (String path : ENTRY_POINTS) {
- MockHttpServletRequest request = signedRequest(path, CLUSTER,
CLOUD_ACCESS_KEY, CLOUD_SECRET_KEY);
+ MockHttpServletRequest request = signedRequest(path, INSTANCE_ID,
CLOUD_ACCESS_KEY, CLOUD_SECRET_KEY);
MockHttpServletResponse response = new MockHttpServletResponse();
assertThatThrownBy(() -> filter.doFilter(request, response,
(verifiedRequest, verifiedResponse) -> {
assertThat(AuthenticatedUserContext.currentUsernameOrSystem()).isEqualTo(CLOUD_ACCESS_KEY);
@@ -175,15 +175,15 @@ class McpCredentialAuthenticationTest {
}
@Test
- void rejectsUnknownTargetBeforeReadingCredentials() throws Exception {
-
when(instanceResolver.findByName(CLUSTER)).thenReturn(Optional.empty());
+ void rejectsUnknownTargetBeforeReadingCredentialsTest() throws Exception {
+
when(instanceResolver.findByName(INSTANCE_ID)).thenReturn(Optional.empty());
assertRejectedAtBothEntryPoints(401);
verifyNoInteractions(adminResolver, cloudCredentialRepository);
}
@Test
- void rejectsMissingCloudCredentialReference() throws Exception {
+ void rejectsMissingCloudCredentialReferenceTest() throws Exception {
instance.setCredentialId(null);
assertRejectedAtBothEntryPoints(401);
@@ -191,7 +191,7 @@ class McpCredentialAuthenticationTest {
}
@Test
- void rejectsMissingCloudCredential() throws Exception {
+ void rejectsMissingCloudCredentialTest() throws Exception {
when(cloudCredentialRepository.findById(7L)).thenReturn(Optional.empty());
assertRejectedAtBothEntryPoints(401);
@@ -199,7 +199,7 @@ class McpCredentialAuthenticationTest {
@ParameterizedTest
@ValueSource(ints = {422, 503})
- void rejectsApacheCredentialBusinessFailures(int code) throws Exception {
+ void rejectsApacheCredentialBusinessFailuresTest(int code) throws
Exception {
instance.setVendor(InstanceVendor.APACHE);
when(adminResolver.resolveCredential(instance))
.thenThrow(new BusinessException(code, "Admin credential
unavailable"));
@@ -208,8 +208,8 @@ class McpCredentialAuthenticationTest {
}
@Test
- void rejectsCredentialConfigurationFailureDuringTargetResolution() throws
Exception {
- when(instanceResolver.findByName(CLUSTER))
+ void rejectsCredentialConfigurationFailureDuringTargetResolutionTest()
throws Exception {
+ when(instanceResolver.findByName(INSTANCE_ID))
.thenThrow(new BusinessException(422, "Admin credential is not
configured"));
assertRejectedAtBothEntryPoints(401);
@@ -217,8 +217,8 @@ class McpCredentialAuthenticationTest {
}
@Test
- void preservesTargetResolutionFailureAsInternalError() throws Exception {
- when(instanceResolver.findByName(CLUSTER))
+ void preservesTargetResolutionFailureAsInternalErrorTest() throws
Exception {
+ when(instanceResolver.findByName(INSTANCE_ID))
.thenThrow(new BusinessException(503, "NameServer
unavailable"));
assertRejectedAtBothEntryPoints(500);
@@ -226,7 +226,7 @@ class McpCredentialAuthenticationTest {
}
@Test
- void preservesCloudCredentialRepositoryFailureAsInternalError() throws
Exception {
+ void preservesCloudCredentialRepositoryFailureAsInternalErrorTest() throws
Exception {
when(cloudCredentialRepository.findById(7L))
.thenThrow(new DataAccessResourceFailureException("Credential
database unavailable"));
@@ -234,7 +234,7 @@ class McpCredentialAuthenticationTest {
}
@Test
- void preservesUnexpectedAdminResolverFailureAsInternalError() throws
Exception {
+ void preservesUnexpectedAdminResolverFailureAsInternalErrorTest() throws
Exception {
instance.setVendor(InstanceVendor.APACHE);
when(adminResolver.resolveCredential(instance))
.thenThrow(new DataAccessResourceFailureException("Credential
resolver unavailable"));
@@ -247,8 +247,8 @@ class McpCredentialAuthenticationTest {
"wrong-access-key, cloud-test-secret",
"cloud-ak, wrong-secret"
})
- void rejectsInvalidCredentials(String accessKey, String secretKey) throws
Exception {
- assertRejectedAtBothEntryPoints(401, CLUSTER, accessKey, secretKey);
+ void rejectsInvalidCredentialsTest(String accessKey, String secretKey)
throws Exception {
+ assertRejectedAtBothEntryPoints(401, INSTANCE_ID, accessKey,
secretKey);
}
@Nested
@@ -276,7 +276,7 @@ class McpCredentialAuthenticationTest {
}
@Test
- void
authenticatesConfiguredTargetWithoutDatabaseRecordAtBothEntryPoints() throws
Exception {
+ void
authenticatesConfiguredTargetWithoutDatabaseRecordAtBothEntryPointsTest()
throws Exception {
for (String path : ENTRY_POINTS) {
MockHttpServletRequest request = signedRequest(
path, CONFIGURED_CLUSTER, ADMIN_ACCESS_KEY,
ADMIN_SECRET_KEY);
@@ -301,7 +301,7 @@ class McpCredentialAuthenticationTest {
@ParameterizedTest
@EnumSource(InstanceVendor.class)
- void
registeredTargetWithMissingCredentialsNeverFallsBackToConfiguration(InstanceVendor
vendor)
+ void
registeredTargetWithMissingCredentialsNeverFallsBackToConfigurationTest(InstanceVendor
vendor)
throws Exception {
instance.setName(CONFIGURED_CLUSTER);
instance.setVendor(vendor);
@@ -318,12 +318,12 @@ class McpCredentialAuthenticationTest {
private void assertRejectedAtBothEntryPoints(int status) throws Exception {
boolean apache = instance.getVendor() == InstanceVendor.APACHE;
- assertRejectedAtBothEntryPoints(status, CLUSTER,
+ assertRejectedAtBothEntryPoints(status, INSTANCE_ID,
apache ? ADMIN_ACCESS_KEY : CLOUD_ACCESS_KEY,
apache ? ADMIN_SECRET_KEY : CLOUD_SECRET_KEY);
}
- private void assertRejectedAtBothEntryPoints(int status, String cluster,
String accessKey, String secretKey)
+ private void assertRejectedAtBothEntryPoints(int status, String
instanceId, String accessKey, String secretKey)
throws Exception {
ObjectMapper objectMapper = new ObjectMapper();
Map<String, String> expectedError = status == 401
@@ -334,7 +334,7 @@ class McpCredentialAuthenticationTest {
"message", "MCP authentication failed unexpectedly.",
"hint", "Retry once; if the failure persists, contact
an administrator.");
for (String path : ENTRY_POINTS) {
- MockHttpServletRequest request = signedRequest(path, cluster,
accessKey, secretKey);
+ MockHttpServletRequest request = signedRequest(path, instanceId,
accessKey, secretKey);
MockHttpServletResponse response = new MockHttpServletResponse();
AtomicBoolean invoked = new AtomicBoolean();
@@ -357,17 +357,17 @@ class McpCredentialAuthenticationTest {
assertThat(AuthenticatedUserContext.currentUserIsAdminOrSystem()).isTrue();
}
- private MockHttpServletRequest signedRequest(String path, String cluster,
String accessKey, String secretKey)
+ private MockHttpServletRequest signedRequest(String path, String
instanceId, String accessKey, String secretKey)
throws Exception {
String timestamp = Long.toString(System.currentTimeMillis());
- String canonical = McpAuthenticator.canonicalRequest(accessKey,
cluster, timestamp, "POST", path);
+ String canonical = McpAuthenticator.canonicalRequest(accessKey,
instanceId, timestamp, "POST", path);
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secretKey.getBytes(StandardCharsets.UTF_8),
"HmacSHA256"));
String signature =
HexFormat.of().formatHex(mac.doFinal(canonical.getBytes(StandardCharsets.UTF_8)));
MockHttpServletRequest request = new MockHttpServletRequest("POST",
path);
request.addHeader(HttpHeaders.AUTHORIZATION,
McpAuthenticator.ALGORITHM + " Credential=" + accessKey + ",
Signature=" + signature);
- request.addHeader(McpAuthenticator.HEADER_CLUSTER, cluster);
+ request.addHeader(McpAuthenticator.HEADER_INSTANCE, instanceId);
request.addHeader(McpAuthenticator.HEADER_TIMESTAMP, timestamp);
return request;
}
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/ops/ai/mcp/McpToolRegistrarTest.java
b/server/src/test/java/org/apache/rocketmq/studio/ops/ai/mcp/McpToolRegistrarTest.java
index e35d4358e..4344af62c 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/ops/ai/mcp/McpToolRegistrarTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/ops/ai/mcp/McpToolRegistrarTest.java
@@ -48,7 +48,7 @@ class McpToolRegistrarTest {
void propagatesTransportAuthenticationToToolExecutor() {
ToolDefinition definition = toolDefinition();
ToolExecutionService toolExecutor = mock(ToolExecutionService.class);
- Map<String, Object> output = Map.of("cluster", "cluster-001");
+ Map<String, Object> output = Map.of("instanceId", "cluster-001");
when(toolExecutor.execute(
same(definition.name()), any(), same(AUTHENTICATION)))
.thenReturn(output);
@@ -59,7 +59,7 @@ class McpToolRegistrarTest {
exchange(AUTHENTICATION),
new McpSchema.CallToolRequest(
definition.name(), Map.of(
- "cluster", "cluster-001",
+ "instanceId", "cluster-001",
"dry_run", true)));
ArgumentCaptor<Map<String, Object>> captor =
ArgumentCaptor.forClass(Map.class);
@@ -70,7 +70,7 @@ class McpToolRegistrarTest {
assertThat(result.structuredContent()).isEqualTo(output);
assertThat(specification.tool().inputSchema()).isEqualTo(definition.inputSchema());
assertThat(specification.tool().description())
- .isEqualTo("Create a RocketMQ topic\nRequires all
capabilities: TOPIC_MANAGEMENT.");
+ .isEqualTo("Update a RocketMQ topic\nRequires all
capabilities: TOPIC_MANAGEMENT.");
assertThat(specification.tool().annotations().destructiveHint()).isFalse();
}
@@ -86,16 +86,16 @@ class McpToolRegistrarTest {
private static ToolDefinition toolDefinition() {
return new ToolDefinition(
- "rmq.topic.create",
- new ToolDefinition.Cli("topic", "create"),
- "Create a RocketMQ topic",
+ "rmq.topic.update",
+ new ToolDefinition.Cli("topic", "update"),
+ "Update a RocketMQ topic",
ToolRiskLevel.L2,
"topic:write",
List.of("TOPIC_MANAGEMENT"),
Map.of(
"type", "object",
- "properties", Map.of("cluster", Map.of("type",
"string")),
- "required", List.of("cluster"),
+ "properties", Map.of("instanceId", Map.of("type",
"string")),
+ "required", List.of("instanceId"),
"additionalProperties", false),
Map.of("type", "object", "additionalProperties", true),
"json",
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/ops/ai/tool/filter/ToolMutationFilterTest.java
b/server/src/test/java/org/apache/rocketmq/studio/ops/ai/tool/filter/ToolMutationFilterTest.java
index bc521c43b..a8e2363d0 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/ops/ai/tool/filter/ToolMutationFilterTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/ops/ai/tool/filter/ToolMutationFilterTest.java
@@ -116,7 +116,7 @@ class ToolMutationFilterTest {
@Test
void invalidTokenPreventsPlanGenerationAndExecution() {
ToolExecutionContext context = context(ToolRiskLevel.L2,
Map.of("topic", "orders", "confirm_token", "invalid"));
- var failure =
ToolError.CONFIRMATION_TOKEN_INVALID.exception("rmq.topic.create");
+ var failure =
ToolError.CONFIRMATION_TOKEN_INVALID.exception("rmq.topic.update");
doThrow(failure).when(tokens).verify(context);
assertThatThrownBy(() -> chain.execute(new ToolInvocation(context,
handler))).isSameAs(failure);
@@ -125,8 +125,8 @@ class ToolMutationFilterTest {
}
private static ToolExecutionContext context(ToolRiskLevel risk,
Map<String, Object> input) {
- ToolDefinition definition = new ToolDefinition("rmq.topic.create",
- new ToolDefinition.Cli("topic", "create"), "Create topic",
risk,
+ ToolDefinition definition = new ToolDefinition("rmq.topic.update",
+ new ToolDefinition.Cli("topic", "update"), "Create topic",
risk,
"topic:write", List.of(), Map.of(), Map.of(), null, false,
null);
return ToolExecutionContext.of("instance-a", definition, input,
"alice");
}
@@ -145,7 +145,7 @@ class ToolMutationFilterTest {
@Override
public String name() {
- return "rmq.topic.create";
+ return "rmq.topic.update";
}
@Override
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/ops/ai/tool/service/ToolDiscoveryServiceTest.java
b/server/src/test/java/org/apache/rocketmq/studio/ops/ai/tool/service/ToolDiscoveryServiceTest.java
index 0ee8758c9..882af7c46 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/ops/ai/tool/service/ToolDiscoveryServiceTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/ops/ai/tool/service/ToolDiscoveryServiceTest.java
@@ -73,7 +73,7 @@ class ToolDiscoveryServiceTest {
}
@Test
- void discoveryWithInstanceExposesSupportedTools() {
+ void discoveryWithInstanceExposesSupportedToolsTest() {
when(instanceRepository.findByIdentifier("instance-a"))
.thenReturn(Optional.of(InstanceVO.builder()
.name("instance-a")
@@ -85,16 +85,16 @@ class ToolDiscoveryServiceTest {
.extracting(AiToolVO::getName)
.contains(
"rmq.cluster.list",
- "rmq.capabilities",
+ "rmq.instance.capabilities",
"rmq.dashboard.summary",
"rmq.topic.list",
"rmq.topic.route",
- "rmq.topic.send",
+ "rmq.message.send",
"rmq.group.list",
"rmq.group.reset_offset",
"rmq.alert.rule.list")
- .contains("rmq.nameserver.config.diff")
- .doesNotContain("rmq.proxy.config_update",
"rmq.lite_topic.list", "rmq.lite_topic.create");
+ .contains("rmq.nameserver.config")
+ .doesNotContain("rmq.proxy.config_update");
}
@ParameterizedTest
@@ -114,17 +114,20 @@ class ToolDiscoveryServiceTest {
var tools =
discoveryService.listTools("instance-a").stream().map(AiToolVO::getName).toList();
assertThat(tools)
- .contains("rmq.capabilities", "rmq.topic.list",
"rmq.topic.create", "rmq.group.list",
+ .contains("rmq.instance.capabilities", "rmq.topic.list",
"rmq.topic.update", "rmq.group.list",
"rmq.acl.list", "rmq.user.list")
- .doesNotContain("rmq.cluster.list", "rmq.broker.list",
"rmq.nameserver.config.diff", "rmq.dlq.list",
- "rmq.topic.route", "rmq.topic.send",
"rmq.group.reset_offset",
- "rmq.lite_topic.list", "rmq.lite_topic.create");
+ .doesNotContain("rmq.cluster.list", "rmq.broker.list",
"rmq.nameserver.config", "rmq.group.dlq_list",
+ "rmq.topic.route", "rmq.message.send",
"rmq.group.reset_offset");
}
+ /** The AI page lists platform tools without binding an Instance, so a
missing target is not an error. */
@Test
- void discoveryRejectsMissingOrUnregisteredNames() {
- assertThatThrownBy(() -> discoveryService.listTools(" "))
- .isInstanceOfSatisfying(ToolExecutionException.class,
- error -> assertThat(error.getCode()).isEqualTo(400));
+ void discoveryWithoutATargetExposesNoToolsTest() {
+ assertThat(discoveryService.listTools(null)).isEmpty();
+ assertThat(discoveryService.listTools(" ")).isEmpty();
+ }
+
+ @Test
+ void discoveryRejectsAnUnregisteredInstanceTest() {
assertThatThrownBy(() -> discoveryService.listTools("unknown"))
.isInstanceOfSatisfying(ToolExecutionException.class,
error -> assertThat(error.getCode()).isEqualTo(404));
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/ops/ai/tool/service/ToolExecutorInvocationTest.java
b/server/src/test/java/org/apache/rocketmq/studio/ops/ai/tool/service/ToolExecutorInvocationTest.java
index ff43d722d..2a4d759a0 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/ops/ai/tool/service/ToolExecutorInvocationTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/ops/ai/tool/service/ToolExecutorInvocationTest.java
@@ -79,22 +79,22 @@ class ToolExecutorInvocationTest {
}
@Test
- void capturesConsoleUserBeforeEnteringChain() {
+ void capturesConsoleUserBeforeEnteringChainTest() {
AuthenticatedUserContext.setUsername("alice");
when(chain.execute(any(ToolInvocation.class))).thenAnswer(invocation
-> {
AuthenticatedUserContext.setUsername("bob");
return null;
});
- executor.execute("rmq.topic.list", Map.of("cluster", "instance-a"));
+ executor.execute("rmq.topic.list", Map.of("instanceId", "instance-a"));
ArgumentCaptor<ToolInvocation> invocation =
ArgumentCaptor.forClass(ToolInvocation.class);
verify(chain).execute(invocation.capture());
assertThat(invocation.getValue().context().principal()).isEqualTo("alice");
}
@Test
- void passesBoundInstancePrincipalAndHandlerToChain() {
+ void passesBoundInstancePrincipalAndHandlerToChainTest() {
AuthenticatedUserContext.setUsername("console-user");
- Map<String, Object> input = Map.of("cluster", "instance-a");
+ Map<String, Object> input = Map.of("instanceId", "instance-a");
Map<String, Object> output = Map.of("items", List.of());
when(chain.execute(any(ToolInvocation.class))).thenReturn(output);
@@ -105,16 +105,16 @@ class ToolExecutorInvocationTest {
assertThat(invocation.getValue().handler()).isSameAs(handler);
assertThat(invocation.getValue().context().definition()).isSameAs(definition);
assertThat(invocation.getValue().context().input()).containsExactlyEntriesOf(input);
-
assertThat(invocation.getValue().context().cluster()).isEqualTo("instance-a");
+
assertThat(invocation.getValue().context().instanceId()).isEqualTo("instance-a");
assertThat(invocation.getValue().context().principal()).isEqualTo("mcp-access-key");
assertThat(result).isSameAs(output);
verifyNoInteractions(instances);
}
@Test
- void rejectsMissingBlankAndNonStringClusterBeforeFilters() {
+ void rejectsMissingBlankAndNonStringInstanceIdBeforeFiltersTest() {
for (Map<String, Object> input : List.<Map<String, Object>>of(
- Map.of(), Map.of("cluster", " "), Map.of("cluster", 1),
Map.of("cluster", true))) {
+ Map.of(), Map.of("instanceId", " "), Map.of("instanceId", 1),
Map.of("instanceId", true))) {
assertThatThrownBy(() -> executor.execute("rmq.topic.list", input))
.isInstanceOfSatisfying(ToolExecutionException.class,
error ->
assertThat(error.getCode()).isEqualTo(400));
@@ -126,8 +126,8 @@ class ToolExecutorInvocationTest {
}
@Test
- void bodyClusterRequiresARegisteredNameAndNeverFallsBackToNumericId() {
- assertThatThrownBy(() -> executor.execute("rmq.topic.list",
Map.of("cluster", "1")))
+ void
bodyInstanceIdRequiresARegisteredNameAndNeverFallsBackToNumericIdTest() {
+ assertThatThrownBy(() -> executor.execute("rmq.topic.list",
Map.of("instanceId", "1")))
.isInstanceOfSatisfying(ToolExecutionException.class,
error -> assertThat(error.getCode()).isEqualTo(404));
verify(instances).findByName("1");
@@ -136,39 +136,39 @@ class ToolExecutorInvocationTest {
}
@Test
- void rejectsDifferentAuthenticatedInstanceBeforeFilters() {
+ void rejectsDifferentAuthenticatedInstanceBeforeFiltersTest() {
InstanceVO other = InstanceVO.builder().name("instance-b").build();
other.setId(2L);
when(instances.findByName("instance-b")).thenReturn(Optional.of(other));
assertThatThrownBy(() -> executor.execute("rmq.topic.list",
- Map.of("cluster", "instance-a"), new
McpAuthentication("instance-b", "shared-access-key")))
+ Map.of("instanceId", "instance-a"), new
McpAuthentication("instance-b", "shared-access-key")))
.isInstanceOfSatisfying(ToolExecutionException.class,
error -> assertThat(error.getCode()).isEqualTo(403));
verifyNoInteractions(chain);
}
@Test
- void rejectsDifferentAuthenticatedClusterWithoutResolvingEitherName() {
+ void rejectsDifferentAuthenticatedTargetWithoutResolvingEitherNameTest() {
assertThatThrownBy(() -> executor.execute("rmq.topic.list",
- Map.of("cluster", "instance-a"), new McpAuthentication("1",
"access-key")))
+ Map.of("instanceId", "instance-a"), new McpAuthentication("1",
"access-key")))
.isInstanceOfSatisfying(ToolExecutionException.class,
error -> assertThat(error.getCode()).isEqualTo(403));
verifyNoInteractions(instances, chain);
}
@Test
- void acceptsAuthenticatedConfiguredClusterWithoutDatabaseIdentity() {
- executor.execute("rmq.topic.list", Map.of("cluster", "DefaultCluster"),
+ void acceptsAuthenticatedConfiguredClusterWithoutDatabaseIdentityTest() {
+ executor.execute("rmq.topic.list", Map.of("instanceId",
"DefaultCluster"),
new McpAuthentication("DefaultCluster", "access-key"));
ArgumentCaptor<ToolInvocation> invocation =
ArgumentCaptor.forClass(ToolInvocation.class);
verify(chain).execute(invocation.capture());
-
assertThat(invocation.getValue().context().cluster()).isEqualTo("DefaultCluster");
+
assertThat(invocation.getValue().context().instanceId()).isEqualTo("DefaultCluster");
verifyNoInteractions(instances);
}
@Test
- void missingMcpAuthenticationDoesNotBecomeAConsoleCall() {
- assertThatThrownBy(() -> executor.execute("rmq.topic.list",
Map.of("cluster", "instance-a"), null))
+ void missingMcpAuthenticationDoesNotBecomeAConsoleCallTest() {
+ assertThatThrownBy(() -> executor.execute("rmq.topic.list",
Map.of("instanceId", "instance-a"), null))
.isInstanceOf(ToolExecutionException.class);
verifyNoInteractions(instances, chain);
}
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/ops/ai/tool/service/ToolInstanceRoutingTest.java
b/server/src/test/java/org/apache/rocketmq/studio/ops/ai/tool/service/ToolInstanceRoutingTest.java
index 7f32f7d6b..b30b997e4 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/ops/ai/tool/service/ToolInstanceRoutingTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/ops/ai/tool/service/ToolInstanceRoutingTest.java
@@ -20,6 +20,7 @@ import org.apache.rocketmq.studio.instance.InstanceResolver;
import
org.apache.rocketmq.studio.provider.apache.RocketMQDefaultClusterResolver;
import org.apache.rocketmq.studio.audit.OperationAuditService;
+import org.apache.rocketmq.studio.cluster.broker.RuntimeAdminClientResolver;
import org.apache.rocketmq.studio.common.domain.enums.InstanceType;
import org.apache.rocketmq.studio.common.domain.enums.InstanceVendor;
import org.apache.rocketmq.studio.instance.InstanceRepository;
@@ -60,32 +61,33 @@ class ToolInstanceRoutingTest {
@ParameterizedTest
@CsvSource({"APACHE,false", "APACHE,true", "ALIYUN,false",
"TENCENT,false"})
- void
bodyClusterRoutesCapabilitiesMetadataAndAuditToTheSameTarget(InstanceVendor
vendor, boolean configured) {
- String cluster = configured ? "DefaultCluster" : "prod-orders";
+ void
bodyInstanceIdRoutesCapabilitiesMetadataAndAuditToTheSameTargetTest(InstanceVendor
vendor, boolean configured) {
+ String target = configured ? "DefaultCluster" : "prod-orders";
InstanceRepository instances = mock(InstanceRepository.class);
RocketMQDefaultClusterResolver configuredClusters =
mock(RocketMQDefaultClusterResolver.class);
InstanceResolver targets = new InstanceResolver(instances,
configuredClusters);
- InstanceVO instance = InstanceVO.builder().name(cluster).vendor(vendor)
+ InstanceVO instance = InstanceVO.builder().name(target).vendor(vendor)
.type(vendor == InstanceVendor.APACHE ?
InstanceType.PROXY_CLUSTER : InstanceType.CLOUD)
.endpoint("selected-ns:9876").cloudInstanceId("vendor-resource-id").build();
if (configured) {
-
when(configuredClusters.find(cluster)).thenReturn(Optional.of(instance));
+
when(configuredClusters.find(target)).thenReturn(Optional.of(instance));
} else {
instance.setId(7L);
-
when(instances.findByName(cluster)).thenReturn(Optional.of(instance));
-
when(instances.findByIdentifier(cluster)).thenReturn(Optional.of(instance));
+
when(instances.findByName(target)).thenReturn(Optional.of(instance));
+
when(instances.findByIdentifier(target)).thenReturn(Optional.of(instance));
}
InstanceProvider selected = mock(InstanceProvider.class);
when(selected.vendor()).thenReturn(vendor);
when(selected.capabilities()).thenReturn(Set.of(InstanceCapability.TOPIC_MANAGEMENT));
- when(selected.listTopics(cluster, null, null)).thenReturn(List.of());
+ when(selected.listTopics(target, null, null)).thenReturn(List.of());
InstanceProviderRegistry registry = new
InstanceProviderRegistry(List.of(selected),
List.of(),
targets);
MetadataProvider globalMetadata = mock(MetadataProvider.class);
AdminClient globalAdmin = mock(AdminClient.class);
MetadataService metadata = new MetadataService(globalMetadata,
globalAdmin, registry, targets,
- mock(OperationAuditService.class), mock(MessageService.class));
+ mock(OperationAuditService.class), mock(MessageService.class),
+ mock(RuntimeAdminClientResolver.class));
TopicListToolHandler handler = new TopicListToolHandler(metadata);
ToolDefinition definition = new ToolCatalog(new
DefaultResourceLoader()).getDefinition(handler.name());
ToolCatalog catalog = mock(ToolCatalog.class);
@@ -101,11 +103,11 @@ class ToolInstanceRoutingTest {
filters,
targets);
- executor.execute(handler.name(), Map.of("cluster", cluster));
+ executor.execute(handler.name(), Map.of(ToolCatalog.INSTANCE_ID_FIELD,
target));
verify(selected).capabilities();
- verify(selected).listTopics(cluster, null, null);
- verify(audit).record(anyString(), anyString(), eq(handler.name()),
eq(cluster), isNull(), eq("SUCCESS"));
+ verify(selected).listTopics(target, null, null);
+ verify(audit).record(anyString(), anyString(), eq(handler.name()),
eq(target), isNull(), eq("SUCCESS"));
verifyNoInteractions(globalMetadata, globalAdmin);
}
}
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/ops/ai/tool/service/ToolOutputSchemaContractTest.java
b/server/src/test/java/org/apache/rocketmq/studio/ops/ai/tool/service/ToolOutputSchemaContractTest.java
index 974035c71..6cfee313d 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/ops/ai/tool/service/ToolOutputSchemaContractTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/ops/ai/tool/service/ToolOutputSchemaContractTest.java
@@ -17,72 +17,300 @@
package org.apache.rocketmq.studio.ops.ai.tool.service;
import org.apache.rocketmq.studio.cluster.broker.BrokerVO;
-import org.apache.rocketmq.studio.cluster.client.ClientConnectionVO;
+import org.apache.rocketmq.studio.cluster.metrics.MetricDataVO;
import org.apache.rocketmq.studio.cluster.proxy.ProxyVO;
+import org.apache.rocketmq.studio.common.config.LegacyJackson2Config;
import org.apache.rocketmq.studio.common.domain.enums.BrokerStatus;
-import org.apache.rocketmq.studio.common.domain.enums.ClientLanguage;
-import org.apache.rocketmq.studio.common.domain.enums.ClientType;
import org.apache.rocketmq.studio.common.domain.enums.ClusterStatus;
-import org.apache.rocketmq.studio.common.domain.enums.Protocol;
+import org.apache.rocketmq.studio.common.domain.enums.ConsumeType;
+import org.apache.rocketmq.studio.common.domain.enums.SubscriptionMode;
+import org.apache.rocketmq.studio.common.domain.enums.TopicPerm;
+import org.apache.rocketmq.studio.common.domain.enums.TopicType;
+import org.apache.rocketmq.studio.instance.acl.AclRuleVO;
import org.apache.rocketmq.studio.ops.ai.tool.catalog.ToolCatalog;
-import
org.apache.rocketmq.studio.ops.ai.tool.contract.common.ClusterListOutput;
+import org.apache.rocketmq.studio.ops.ai.tool.contract.acl.AclRuleItem;
+import org.apache.rocketmq.studio.ops.ai.tool.contract.acl.AclUserItem;
+import org.apache.rocketmq.studio.ops.ai.tool.contract.alert.AlertRuleListItem;
+import
org.apache.rocketmq.studio.ops.ai.tool.contract.broker.BrokerConfigOutput;
+import
org.apache.rocketmq.studio.ops.ai.tool.contract.broker.BrokerDescribeOutput;
+import org.apache.rocketmq.studio.ops.ai.tool.contract.cluster.ClusterListItem;
+import org.apache.rocketmq.studio.ops.ai.tool.contract.common.ListOutput;
+import org.apache.rocketmq.studio.ops.ai.tool.contract.common.MutationOutput;
+import org.apache.rocketmq.studio.ops.ai.tool.contract.common.PageOutput;
+import org.apache.rocketmq.studio.ops.ai.tool.contract.group.GroupDetailOutput;
+import org.apache.rocketmq.studio.ops.ai.tool.contract.group.GroupListItem;
+import org.apache.rocketmq.studio.ops.ai.tool.contract.group.ResetOffsetOutput;
+import
org.apache.rocketmq.studio.ops.ai.tool.contract.instance.InstanceCapabilitiesOutput;
+import org.apache.rocketmq.studio.ops.ai.tool.contract.message.MessageItem;
+import
org.apache.rocketmq.studio.ops.ai.tool.contract.message.MessageQueryDlqOutput;
+import
org.apache.rocketmq.studio.ops.ai.tool.contract.message.MessageRedeliveryDlqOutput;
+import
org.apache.rocketmq.studio.ops.ai.tool.contract.message.MessageRedeliveryOutput;
+import
org.apache.rocketmq.studio.ops.ai.tool.contract.message.MessageSendOutput;
+import
org.apache.rocketmq.studio.ops.ai.tool.contract.message.MessageTraceOutput;
+import
org.apache.rocketmq.studio.ops.ai.tool.contract.nameserver.NameserverConfigItem;
+import org.apache.rocketmq.studio.ops.ai.tool.contract.ops.AuditItem;
+import org.apache.rocketmq.studio.ops.ai.tool.contract.proxy.ProxyConfigItem;
+import org.apache.rocketmq.studio.ops.ai.tool.contract.topic.TopicDetailOutput;
+import org.apache.rocketmq.studio.ops.ai.tool.contract.topic.TopicListItem;
+import org.apache.rocketmq.studio.ops.ai.tool.contract.topic.TopicOutput;
+import
org.apache.rocketmq.studio.ops.ai.tool.contract.topic.TopicQueueStatsItem;
+import org.apache.rocketmq.studio.ops.ai.tool.contract.topic.TopicRouteItem;
+import org.apache.rocketmq.studio.ops.ai.tool.core.ToolDefinition;
+import
org.apache.rocketmq.studio.ops.ai.tool.handler.dashboard.DashboardSummaryToolHandler;
+import
org.apache.rocketmq.studio.ops.ai.tool.handler.nameserver.NameserverListToolHandler;
+import org.apache.rocketmq.studio.ops.ai.tool.contract.plan.ToolPlan;
import org.junit.jupiter.api.Test;
import org.springframework.core.io.DefaultResourceLoader;
import tools.jackson.databind.json.JsonMapper;
import java.time.LocalDateTime;
+import java.util.LinkedHashMap;
import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.TreeSet;
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Golden output-contract sweep: one representative sample per catalog tool,
validated through
+ * the same {@link ToolSchemaValidator} pipeline the runtime filter chain
uses. Keeps the yaml
+ * output schemas and the Java contract records in a 40/40 bijection — a
schema or record drift
+ * fails here with the exact offending tool and field.
+ */
class ToolOutputSchemaContractTest {
+ private static final String INSTANCE = "instance-a";
+ private static final long TIMESTAMP = 1784246400000L;
+
private final ToolCatalog catalog = new ToolCatalog(new
DefaultResourceLoader());
private final ToolSchemaValidator validator = new ToolSchemaValidator(
catalog,
- new
com.fasterxml.jackson.databind.ObjectMapper().findAndRegisterModules(),
- new JsonMapper());
+ new LegacyJackson2Config().jackson2ObjectMapper(),
+ JsonMapper.builder().build());
+
+ @Test
+ void validatesEveryToolOutputSampleTest() {
+ samples().forEach((tool, outputs) -> outputs.forEach(output -> {
+ ToolDefinition definition = catalog.getDefinition(tool);
+ validator.validateOutput(definition, output);
+ }));
+ }
@Test
- void validatesTypedListOutputsAgainstCatalogSchemas() {
- ClientConnectionVO client = ClientConnectionVO.builder()
- .clientId("client-1")
- .type(ClientType.Producer)
- .groupOrTopic("orders")
- .producerGroup("orders-producer")
- .protocol(Protocol.Remoting)
- .address("127.0.0.1:12000")
- .language(ClientLanguage.Java)
- .version("5.0.0")
- .connectedAt(LocalDateTime.of(2026, 9, 7, 1, 0))
- .partial(false)
- .clusterName("cluster-a")
+ void coversEveryCatalogToolTest() {
+ Set<String> covered = new TreeSet<>(samples().keySet());
+ Set<String> declared = new TreeSet<>(catalog.list().stream()
+ .map(ToolDefinition::name)
+ .toList());
+ assertThat(covered).isEqualTo(declared);
+ }
+
+ private static Map<String, List<Object>> samples() {
+ Map<String, List<Object>> samples = new LinkedHashMap<>();
+
+ AclRuleItem aclRule = new AclRuleItem(
+ "1", "alice", "orders", "TOPIC", "LITERAL",
+ List.of("PUB"), "ALLOW", INSTANCE, "v2",
"2026-08-22T08:00:00");
+ AclRuleVO aclRuleVO = AclRuleVO.builder()
+ .id(1L)
+ .principal("alice")
+ .resource("orders")
+ .resourceType("TOPIC")
+ .resourcePattern("LITERAL")
+ .actions(List.of("PUB"))
+ .decision("ALLOW")
+ .scope(INSTANCE)
+ .aclVersion("v2")
+ .gmtCreate(LocalDateTime.of(2026, 8, 22, 8, 0))
.build();
+ samples.put("rmq.acl.get", List.of(aclRule));
+ samples.put("rmq.acl.list", List.of(new PageOutput<>(1, 20, 1L,
List.of(aclRule))));
+ samples.put("rmq.acl.create", List.of(planned(), executed(aclRuleVO)));
+ samples.put("rmq.acl.update", List.of(planned(), executed(aclRuleVO)));
+ samples.put("rmq.acl.delete", List.of(planned(), executedVoid()));
+
+ AclUserItem user = new AclUserItem("1", "alice", true,
List.of(INSTANCE));
+ samples.put("rmq.user.get", List.of(user));
+ samples.put("rmq.user.list", List.of(new ListOutput<>(List.of(user))));
+ samples.put("rmq.user.create", List.of(planned(), executed(user)));
+ samples.put("rmq.user.delete", List.of(planned(), executedVoid()));
+
+ samples.put("rmq.alert.rule.list", List.of(new ListOutput<>(List.of(
+ new AlertRuleListItem(1L, "consumer-lag",
"consumer.lag.total", ">",
+ 1000.0, "count", "5m", List.of("dingtalk"), true, "lag
alert")))));
+
+ samples.put("rmq.audit.list", List.of(new PageOutput<>(1, 20, 1L,
List.of(
+ new AuditItem(1L, "2026-08-22T08:00:00", "admin",
"CREATE_TOPIC", "TOPIC",
+ "orders", INSTANCE, "{}", "SUCCESS", null)))));
+
+ samples.put("rmq.dashboard.summary", List.of(new
DashboardSummaryToolHandler.Output(
+ List.of(new DashboardSummaryToolHandler.Cluster(
+ INSTANCE, INSTANCE, "V4_DIRECT", "healthy",
+ 1, 0, 2, 3, 10L, 9L, "V5_5_0", List.of(1, 2))),
+ new DashboardSummaryToolHandler.Stats(
+ 1, 1, 1, 0, 1, 2, 3, 100L, 5L, 10L, 9L))));
+
+ samples.put("rmq.instance.capabilities", List.of(
+ new InstanceCapabilitiesOutput(INSTANCE,
List.of("TOPIC_MANAGEMENT"))));
+ samples.put("rmq.instance.metrics", List.of(MetricDataVO.builder()
+ .resultType("matrix")
+ .series(List.of(MetricDataVO.MetricSeriesVO.builder()
+ .labels(Map.of("cluster", "rmq-a"))
+ .values(List.of(MetricDataVO.MetricSampleVO.builder()
+ .timestamp(1784246400.0)
+ .value("1.5")
+ .build()))
+ .build()))
+ .warnings(List.of())
+ .build()));
+
BrokerVO broker = BrokerVO.builder()
.name("broker-a")
.addr("127.0.0.1:10911")
- .version("5.0.0")
+ .version("V5_5_0")
.status(BrokerStatus.running)
.diskUsage(0.25)
.tpsIn(10)
.tpsOut(9)
+ .putMessagesToday(100)
+ .putMessagesYesterday(90)
+ .getMessagesToday(80)
+ .getMessagesYesterday(70)
.runtimeStatsAvailable(true)
.build();
- ProxyVO proxy = ProxyVO.builder()
+ samples.put("rmq.broker.list", List.of(new
ListOutput<>(List.of(broker))));
+ samples.put("rmq.broker.describe", List.of(new BrokerDescribeOutput(
+ "broker-a", "127.0.0.1:10911", "V5_5_0", BrokerStatus.running,
+ 0.25, 10L, 9L, 100L, 90L, 80L, 70L, true)));
+ samples.put("rmq.broker.config", List.of(new BrokerConfigOutput(
+ "rmq-a", true, false, 1, 1,
+ List.of("maxMessageSize"),
+ List.of(new BrokerConfigOutput.BrokerStatus(
+ "broker-a", "127.0.0.1:10911", true, null)),
+ List.of(new BrokerConfigOutput.ConfigDifference(
+ "maxMessageSize", "maxMessageSize",
+ List.of(new BrokerConfigOutput.ConfigValue(
+ "broker-a", "127.0.0.1:10911", true,
"4194304")))))));
+
+ samples.put("rmq.cluster.list", List.of(new ListOutput<>(List.of(
+ new ClusterListItem("rmq-a", "127.0.0.1:10911", "broker-a",
0L, "V5_5_0")))));
+
+ samples.put("rmq.nameserver.list", List.of(new ListOutput<>(List.of(
+ new NameserverListToolHandler.Item(
+ "127.0.0.1:9876", "127.0.0.1:9876", "127.0.0.1:9876",
+ null, null, "UNKNOWN", null)))));
+ samples.put("rmq.nameserver.config", List.of(new ListOutput<>(List.of(
+ new NameserverConfigItem("127.0.0.1:9876",
Map.of("orderMessageEnable", "false"))))));
+
+ samples.put("rmq.proxy.list", List.of(new
ListOutput<>(List.of(ProxyVO.builder()
.addr("127.0.0.1:8081")
.status(ClusterStatus.healthy)
.connections(3)
.grpcPort(8081)
.remotingPort(8080)
+ .build()))));
+ samples.put("rmq.proxy.config", List.of(new ListOutput<>(List.of(
+ new ProxyConfigItem("127.0.0.1:8081", "healthy", 3, 8081, 8080,
+ true, true, "V5_5_0")))));
+
+ TopicListItem topicItem = new TopicListItem(
+ "orders", INSTANCE, TopicType.NORMAL, 8, 8, TopicPerm.RW,
100L, 1.5, 2);
+ samples.put("rmq.topic.list", List.of(new
ListOutput<>(List.of(topicItem))));
+ TopicRouteItem route = new TopicRouteItem(
+ "broker-a", "127.0.0.1:10911", "127.0.0.1:10911",
+ Map.of(0L, "127.0.0.1:10911"), List.of(0L), 1, 8, 8,
+ "RW", 6, true, true, 0);
+ samples.put("rmq.topic.route", List.of(new
ListOutput<>(List.of(route))));
+ samples.put("rmq.topic.detail", List.of(new TopicDetailOutput(
+ INSTANCE, "orders", INSTANCE, TopicType.NORMAL, 8, 8,
TopicPerm.RW,
+ 100L, 1.5, 2, "order topic",
+ List.of(new TopicDetailOutput.ConsumerGroup(
+ "cg-orders", "CLUSTERING", "CLUSTERING", 1.5, 50L,
true)),
+ List.of(new TopicDetailOutput.Route(
+ "broker-a", "127.0.0.1:10911", "127.0.0.1:10911",
+ Map.of(0L, "127.0.0.1:10911"), List.of(0L), 1, 8, 8,
+ "RW", 6, true, true, 0)),
+ List.of(new TopicQueueStatsItem("broker-a", 0, 0L, 100L,
TIMESTAMP)))));
+ samples.put("rmq.topic.update", List.of(planned(), executed(new
TopicOutput(
+ "orders", INSTANCE, "NORMAL", 8, 8, "RW", "order topic"))));
+ samples.put("rmq.topic.delete", List.of(planned(), executedVoid()));
+
+ GroupListItem groupItem = new GroupListItem(
+ "cg-orders", INSTANCE, SubscriptionMode.Push,
ConsumeType.CLUSTERING,
+ 16, 2, 100L, List.of("orders"));
+ samples.put("rmq.group.list", List.of(new
ListOutput<>(List.of(groupItem))));
+ samples.put("rmq.group.detail", List.of(new GroupDetailOutput(
+ INSTANCE, "cg-orders", SubscriptionMode.Push,
ConsumeType.CLUSTERING,
+ 2, 100L, List.of("orders"), "TAG", "Concurrently", 16, 0,
+ List.of(new GroupDetailOutput.Subscription(
+ "orders", "*", "TAG", "STANDARD", "CONSISTENT")),
+ List.of(new GroupDetailOutput.Instance(
+ "client-1", "gRPC", "127.0.0.1:50000",
List.of("orders"),
+ "2026-08-22T09:30:00", Map.of("orders", 10L))),
+ new GroupDetailOutput.Health("HEALTHY", List.of()),
+ List.of(groupItem),
+ new GroupDetailOutput.Progress(100L, List.of(
+ new GroupDetailOutput.QueueProgress("broker-a", 0,
120L, 90L, 30L))),
+ new GroupDetailOutput.Clients(1, List.of(
+ new GroupDetailOutput.Client(
+ "client-1", "gRPC", "127.0.0.1:50000", "JAVA",
"5.0.7",
+ true, List.of("orders"), "2026-08-22T09:30:00",
+ Map.of("orders", 10L)))))));
+ samples.put("rmq.group.update", List.of(planned(),
executed(groupItem)));
+ samples.put("rmq.group.delete", List.of(planned(), executedVoid()));
+ samples.put("rmq.group.reset_offset", List.of(
+ planned(),
+ executed(new ResetOffsetOutput("cg-orders", "orders",
TIMESTAMP, true))));
+
+ MessageItem message = new MessageItem(
+ "MSG-1", "orders", "tagA", "keyA", TIMESTAMP,
+ "127.0.0.1:10911", "127.0.0.1:50000", "aGVsbG8=", "BASE64",
false, 5);
+ samples.put("rmq.message.query", List.of(new
ListOutput<>(List.of(message))));
+ samples.put("rmq.message.query_by_topic", List.of(new
ListOutput<>(List.of(message))));
+ samples.put("rmq.message.query_by_offset", List.of(new
ListOutput<>(List.of(message))));
+ samples.put("rmq.message.query_dlq", List.of(
+ MessageQueryDlqOutput.ofGroups(INSTANCE, 1, 20, 1L, List.of(
+ new MessageQueryDlqOutput.DlqGroupItem(
+ "cg-orders", "%DLQ%cg-orders", 5, 16,
"CONSUMED",
+ true, "2026-08-22T09:30:00"))),
+ MessageQueryDlqOutput.ofMessages(INSTANCE, "cg-orders", 1, 20,
1L, List.of(
+ new MessageQueryDlqOutput.DlqMessageItem(
+ "MSG-9", "%DLQ%cg-orders", 0, 12L, TIMESTAMP,
"keyA", "hello")))));
+ samples.put("rmq.message.trace", List.of(new MessageTraceOutput(
+ "MSG-1",
+ List.of(new MessageTraceOutput.Node(
+ "SEND", TIMESTAMP, "SUCCESS", 5L, "message sent")),
+ List.of(new MessageTraceOutput.ConsumerStatus(
+ "cg-orders", "CONSUMED", TIMESTAMP, 0)))));
+ samples.put("rmq.message.send", List.of(planned(), executed(
+ new MessageSendOutput("MSG-1", "OFF-1", TIMESTAMP))));
+ samples.put("rmq.message.redelivery", List.of(planned(), executed(
+ new MessageRedeliveryOutput("MSG-1", "MSG-2",
"%RETRY%cg-orders"))));
+ samples.put("rmq.message.redelivery_dlq", List.of(planned(), executed(
+ new MessageRedeliveryDlqOutput(5, 5, 0, "ALL_RESENT", false,
0))));
+
+ return samples;
+ }
+
+ private static ToolPlan plan() {
+ return ToolPlan.builder("preview summary")
+ .impact("one impact")
.build();
+ }
+
+ private static <R> MutationOutput<R> planned() {
+ return new MutationOutput<>(
+ MutationOutput.Status.PLANNED, INSTANCE, plan(),
"confirm-token-1", null);
+ }
+
+ private static <R> MutationOutput<R> executed(R result) {
+ return new MutationOutput<>(
+ MutationOutput.Status.EXECUTED, INSTANCE, plan(), null,
result);
+ }
- validator.validateOutput(
- catalog.getDefinition("rmq.client.list"),
- new ClusterListOutput<>("cluster-a", List.of(client)));
- validator.validateOutput(
- catalog.getDefinition("rmq.broker.list"),
- new ClusterListOutput<>("cluster-a", List.of(broker)));
- validator.validateOutput(
- catalog.getDefinition("rmq.proxy.list"),
- new ClusterListOutput<>("cluster-a", List.of(proxy)));
+ private static MutationOutput<Void> executedVoid() {
+ return executed(null);
}
}
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/ops/ai/tool/service/ToolTokenConfigurationTest.java
b/server/src/test/java/org/apache/rocketmq/studio/ops/ai/tool/service/ToolTokenConfigurationTest.java
index 91381212c..e1508b4cb 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/ops/ai/tool/service/ToolTokenConfigurationTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/ops/ai/tool/service/ToolTokenConfigurationTest.java
@@ -86,13 +86,13 @@ class ToolTokenConfigurationTest {
runner.run(context -> {
assertThat(context).hasNotFailed();
ToolCatalog catalog = context.getBean(ToolCatalog.class);
- ToolExecutionContext request = executionContext(catalog,
"rmq.topic.create", Map.of("topic", "orders"));
+ ToolExecutionContext request = executionContext(catalog,
"rmq.topic.update", Map.of("topic", "orders"));
ToolTokenService tokens = context.getBean(ToolTokenService.class);
assertThatThrownBy(() ->
tokens.issue(request)).isInstanceOfSatisfying(ToolExecutionException.class,
failure ->
assertThat(failure.getErrorCode()).isEqualTo("UNAVAILABLE"));
assertThatThrownBy(() ->
tokens.verify(request)).isInstanceOfSatisfying(ToolExecutionException.class,
failure ->
assertThat(failure.getErrorCode()).isEqualTo("UNAVAILABLE"));
- ToolExecutionContext read = executionContext(catalog,
"rmq.topic.list", Map.of("cluster", "dev"));
+ ToolExecutionContext read = executionContext(catalog,
"rmq.topic.list", Map.of("instanceId", "dev"));
assertThat(context.getBean(ToolFilterChain.class).execute(
new ToolInvocation(read,
previewHandler("rmq.topic.list")))).isEqualTo(read.input());
});
@@ -105,14 +105,14 @@ class ToolTokenConfigurationTest {
assertThat(context).hasNotFailed();
ToolFilterChain chain =
context.getBean(ToolFilterChain.class);
ToolCatalog catalog = context.getBean(ToolCatalog.class);
- MutationToolHandler<Map<String, Object>, Object> handler =
previewHandler("rmq.topic.create");
- ToolExecutionContext preview = executionContext(catalog,
"rmq.topic.create", Map.of(
- "cluster", "dev", "topic", "orders", "dry_run",
true));
+ MutationToolHandler<Map<String, Object>, Object> handler =
previewHandler("rmq.topic.update");
+ ToolExecutionContext preview = executionContext(catalog,
"rmq.topic.update", Map.of(
+ "instanceId", "dev", "topic", "orders", "dry_run",
true));
MutationOutput<?> plan = (MutationOutput<?>)
chain.execute(new ToolInvocation(preview, handler));
Map<?, ?> output = new ObjectMapper().convertValue(plan,
Map.class);
assertThat(output.get("confirm_token")).isInstanceOf(String.class);
- ToolExecutionContext apply = executionContext(catalog,
"rmq.topic.create", Map.of(
- "cluster", "dev", "topic", "orders",
"confirm_token", output.get("confirm_token")));
+ ToolExecutionContext apply = executionContext(catalog,
"rmq.topic.update", Map.of(
+ "instanceId", "dev", "topic", "orders",
"confirm_token", output.get("confirm_token")));
AtomicBoolean executed = new AtomicBoolean();
MutationOutput<?> result = (MutationOutput<?>)
chain.execute(new ToolInvocation(apply, executeHandler(executed)));
assertThat(executed).isTrue();
@@ -150,7 +150,7 @@ class ToolTokenConfigurationTest {
return new MutationToolHandler<>((Class<Map<String, Object>>)
(Class<?>) Map.class) {
@Override
public String name() {
- return "rmq.topic.create";
+ return "rmq.topic.update";
}
@Override
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/ops/ai/tool/service/ToolTokenServiceTest.java
b/server/src/test/java/org/apache/rocketmq/studio/ops/ai/tool/service/ToolTokenServiceTest.java
index 3cca17af3..9a01fa21f 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/ops/ai/tool/service/ToolTokenServiceTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/ops/ai/tool/service/ToolTokenServiceTest.java
@@ -42,8 +42,8 @@ class ToolTokenServiceTest {
private static final byte[] SECRET =
"0123456789abcdef0123456789abcdef".getBytes(StandardCharsets.UTF_8);
private static final Clock CLOCK =
Clock.fixed(Instant.parse("2026-09-11T02:00:00Z"), ZoneOffset.UTC);
- private static final Map<String, Object> INPUT = Map.of("cluster",
"instance-dev", "topic", "orders", "writeQueues", 8);
- private static final String TOKEN =
"djEuMTc4OTA5MjYwMC5gRwUvzs+4BUqDX0svsV1C/jUjLzfv/GDPZpjcG9bvBg==";
+ private static final Map<String, Object> INPUT = Map.of("instanceId",
"instance-dev", "topicName", "orders", "writeQueues", 8);
+ private static final String TOKEN =
"djEuMTc4OTA5MjYwMC5BjvIrDnoYt1d8qKs67Cu8qBBPauIQJ6uJg6OUDD5IIg==";
private final ToolCatalog catalog = new ToolCatalog(new
DefaultResourceLoader());
private final ToolTokenService tokens = new ToolTokenService(new
ObjectMapper(), CLOCK, SECRET);
@@ -56,8 +56,8 @@ class ToolTokenServiceTest {
Map<String, Object> apply = new LinkedHashMap<>();
apply.put("writeQueues", 8);
- apply.put("topic", "orders");
- apply.put("cluster", "instance-dev");
+ apply.put("topicName", "orders");
+ apply.put("instanceId", "instance-dev");
apply.put("dry_run", false);
apply.put("break_glass", true);
apply.put("reason", "reviewed");
@@ -72,9 +72,9 @@ class ToolTokenServiceTest {
void bindsToolCallerInstanceAndBusinessInput() {
List<ToolExecutionContext> changedRequests = List.of(
ToolExecutionContext.of("instance-dev",
catalog.getDefinition("rmq.topic.delete"), INPUT, "alice"),
- ToolExecutionContext.of("instance-dev",
catalog.getDefinition("rmq.topic.create"), INPUT, "bob"),
- ToolExecutionContext.of("other-instance",
catalog.getDefinition("rmq.topic.create"), INPUT, "alice"),
- context(Map.of("cluster", "instance-dev", "topic", "orders",
"writeQueues", 16)));
+ ToolExecutionContext.of("instance-dev",
catalog.getDefinition("rmq.topic.update"), INPUT, "bob"),
+ ToolExecutionContext.of("other-instance",
catalog.getDefinition("rmq.topic.update"), INPUT, "alice"),
+ context(Map.of("instanceId", "instance-dev", "topicName",
"orders", "writeQueues", 16)));
changedRequests.forEach(request -> assertInvalid(tokens,
withToken(request, TOKEN)));
}
@@ -107,22 +107,22 @@ class ToolTokenServiceTest {
@Test
void acceptsRawSignatureContainingDotsAndNonUtf8Bytes() {
- ToolExecutionContext request = context(Map.of("cluster",
"instance-dev", "topic", "orders-6", "writeQueues", 8));
+ ToolExecutionContext request = context(Map.of("instanceId",
"instance-dev", "topicName", "orders-20", "writeQueues", 8));
String token = tokens.issue(request);
-
assertThat(token).isEqualTo("djEuMTc4OTA5MjYwMC7zC5yHz0AaCGORzV7ZLGn0TGM56j74zi5atWjYC9VlMA==");
+
assertThat(token).isEqualTo("djEuMTc4OTA5MjYwMC79LrkcIaZYjpm1r3pi9tpCPstorTcGDQuCw2FQFvMKwQ==");
byte[] content = Base64.getDecoder().decode(token);
assertThat(Arrays.copyOfRange(content, content.length - 32,
content.length)).contains((byte) '.', (byte) 0xf3);
assertThatCode(() -> tokens.verify(withToken(request,
token))).doesNotThrowAnyException();
}
private ToolExecutionContext context(Map<String, Object> input) {
- return ToolExecutionContext.of("instance-dev",
catalog.getDefinition("rmq.topic.create"), input, "alice");
+ return ToolExecutionContext.of("instance-dev",
catalog.getDefinition("rmq.topic.update"), input, "alice");
}
private static ToolExecutionContext withToken(ToolExecutionContext
context, String token) {
Map<String, Object> input = new LinkedHashMap<>(context.input());
input.put("confirm_token", token);
- return ToolExecutionContext.of(context.cluster(),
context.definition(), input, context.principal());
+ return ToolExecutionContext.of(context.instanceId(),
context.definition(), input, context.principal());
}
private static void assertInvalid(ToolTokenService service,
ToolExecutionContext context) {
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/ops/alert/AlertSilenceControllerTest.java
b/server/src/test/java/org/apache/rocketmq/studio/ops/alert/AlertSilenceControllerTest.java
index 00cc48f47..2bb8f5d8c 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/ops/alert/AlertSilenceControllerTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/ops/alert/AlertSilenceControllerTest.java
@@ -16,6 +16,7 @@
*/
package org.apache.rocketmq.studio.ops.alert;
+import org.apache.rocketmq.studio.WebMvcAuthTestSupport;
import org.apache.rocketmq.studio.common.domain.PageResult;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
@@ -40,7 +41,7 @@ import static
org.springframework.test.web.servlet.result.MockMvcResultMatchers.
@WebMvcTest(AlertSilenceController.class)
@AutoConfigureMockMvc(addFilters = false)
-class AlertSilenceControllerTest {
+class AlertSilenceControllerTest extends WebMvcAuthTestSupport {
@Autowired
private MockMvc mockMvc;
diff --git a/web/src/api/ai.test.ts b/web/src/api/ai.test.ts
index 89b71029d..65c4024fa 100644
--- a/web/src/api/ai.test.ts
+++ b/web/src/api/ai.test.ts
@@ -306,12 +306,16 @@ describe('AI API', () => {
});
describe('executeTool', () => {
- it('posts structured input and returns structured output', async () => {
- const input = { cluster: 'cluster-a', topic: 'orders' };
+ it('posts structured input with the instanceId query param and returns
structured output', async () => {
+ const input = { instanceId: 'cluster-a', topicName: 'orders' };
const output = { items: [{ name: 'orders' }], total: 1 };
- mock.onPost('/ai/tools/rmq.topic.list/execute', input).reply(200, {
data: output });
+ mock
+ .onPost('/ai/tools/rmq.topic.list/execute', input, {
+ params: { instanceId: 'cluster-a' },
+ })
+ .reply(200, { data: output });
- await expect(executeTool('rmq.topic.list',
input)).resolves.toEqual(output);
+ await expect(executeTool('rmq.topic.list', input,
'cluster-a')).resolves.toEqual(output);
});
});
});
diff --git a/web/src/api/ai.ts b/web/src/api/ai.ts
index 564af6db0..c05761288 100644
--- a/web/src/api/ai.ts
+++ b/web/src/api/ai.ts
@@ -258,10 +258,15 @@ export async function listTools(cluster?: string) {
return res.data.data;
}
-export async function executeTool(name: string, input: Record<string,
unknown>) {
+export async function executeTool(
+ name: string,
+ input: Record<string, unknown>,
+ instanceId: string,
+) {
const res = await client.post<{ data: unknown }>(
`/ai/tools/${encodeURIComponent(name)}/execute`,
input,
+ { params: { instanceId } },
);
return res.data.data;
}
diff --git a/web/src/pages/ai/__tests__/AiPage.test.tsx
b/web/src/pages/ai/__tests__/AiPage.test.tsx
index 19b0aa9da..ea72b1ff9 100644
--- a/web/src/pages/ai/__tests__/AiPage.test.tsx
+++ b/web/src/pages/ai/__tests__/AiPage.test.tsx
@@ -110,12 +110,12 @@ describe('AiPage tool runner', () => {
]);
vi.mocked(listTools).mockResolvedValue([
{
- name: 'rmq.capabilities',
- description: 'Describe cluster capabilities.',
+ name: 'rmq.instance.capabilities',
+ description: 'Describe instance capabilities.',
parameters: {
type: 'object',
- required: ['cluster'],
- properties: { cluster: { type: 'string' } },
+ required: ['instanceId'],
+ properties: { instanceId: { type: 'string' } },
},
riskLevel: 'L1',
permission: 'cluster:read',
@@ -428,7 +428,7 @@ describe('AiPage tool runner', () => {
it('loads the catalog, creates a schema template, and renders structured
output', async () => {
const user = userEvent.setup();
vi.mocked(executeTool).mockResolvedValue({
- cluster: 'cluster-a',
+ instanceId: 'cluster-a',
capabilities: ['GRPC'],
});
renderPage();
@@ -438,19 +438,23 @@ describe('AiPage tool runner', () => {
const dialog = await screen.findByRole('dialog', { name: 'AI 工具' });
await waitFor(() => expect(listTools).toHaveBeenCalledWith('cluster-a'));
expect(within(dialog).getByText('Cluster A')).toBeInTheDocument();
- expect(within(dialog).getByText('rmq.capabilities')).toBeInTheDocument();
+
expect(within(dialog).getByText('rmq.instance.capabilities')).toBeInTheDocument();
expect(within(dialog).getByText('L1')).toBeInTheDocument();
expect(within(dialog).getByText('cluster:read')).toBeInTheDocument();
const input = within(dialog).getByRole('textbox', { name: '工具参数 JSON' });
- expect(input).toHaveValue('{\n "cluster": "cluster-a"\n}');
- fireEvent.change(input, { target: { value: '{"cluster":"cluster-a"}' } });
+ expect(input).toHaveValue('{\n "instanceId": "cluster-a"\n}');
+ fireEvent.change(input, { target: { value: '{"instanceId":"cluster-a"}' }
});
await user.click(within(dialog).getByRole('button', { name: /执\s*行/ }));
await waitFor(() => {
- expect(executeTool).toHaveBeenCalledWith('rmq.capabilities', {
- cluster: 'cluster-a',
- });
+ expect(executeTool).toHaveBeenCalledWith(
+ 'rmq.instance.capabilities',
+ {
+ instanceId: 'cluster-a',
+ },
+ 'cluster-a',
+ );
});
expect(await
within(dialog).findByTestId('tool-result')).toHaveTextContent('"capabilities":
[');
expect(within(dialog).getByTestId('tool-result')).toHaveTextContent('"GRPC"');
@@ -499,8 +503,8 @@ describe('AiPage tool runner', () => {
description: `Tool for ${cluster}`,
parameters: {
type: 'object',
- required: ['cluster'],
- properties: { cluster: { type: 'string' } },
+ required: ['instanceId'],
+ properties: { instanceId: { type: 'string' } },
},
},
]);
@@ -519,7 +523,7 @@ describe('AiPage tool runner', () => {
await waitFor(() => expect(listTools).toHaveBeenCalledWith('cluster-b'));
expect(within(dialog).getByText('rmq.tool.cluster-b')).toBeInTheDocument();
expect(within(dialog).getByRole('textbox', { name: '工具参数 JSON'
})).toHaveValue(
- '{\n "cluster": "cluster-b"\n}',
+ '{\n "instanceId": "cluster-b"\n}',
);
});
diff --git a/web/src/pages/ai/index.tsx b/web/src/pages/ai/index.tsx
index cbc6a89f8..e5fadb317 100644
--- a/web/src/pages/ai/index.tsx
+++ b/web/src/pages/ai/index.tsx
@@ -170,7 +170,7 @@ const buildToolInputTemplate = (tool: McpTool, cluster?:
string): string => {
const input = Object.fromEntries(
required.map((field) => [
field,
- field === 'cluster' && cluster ? cluster :
defaultSchemaValue(properties[field]),
+ field === 'instanceId' && cluster ? cluster :
defaultSchemaValue(properties[field]),
]),
);
return JSON.stringify(input, null, 2);
@@ -885,14 +885,14 @@ const AiPage = () => {
setToolExecuting(true);
setToolResult(undefined);
try {
- setToolResult(await executeTool(selectedToolName, parsedInput));
+ setToolResult(await executeTool(selectedToolName, parsedInput,
selectedClusterId));
message.success('工具执行成功');
} catch (error) {
message.error(error instanceof Error ? error.message : '工具执行失败');
} finally {
setToolExecuting(false);
}
- }, [selectedToolName, toolExecuting, toolInput]);
+ }, [selectedClusterId, selectedToolName, toolExecuting, toolInput]);
const selectedTool = tools.find((tool) => tool.name === selectedToolName);