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 b88582b12 [ISSUE #4143][ISSUE #4147] feat(ai): bound message queries 
and support custom traces (#4388)
b88582b12 is described below

commit b88582b12a6e93ef7baa2e0ccb9797f3a974c0ac
Author: aias00 <[email protected]>
AuthorDate: Mon Sep 21 05:59:01 2026 -0700

    [ISSUE #4143][ISSUE #4147] feat(ai): bound message queries and support 
custom traces (#4388)
    
    `rmq.message.query` and `rmq.message.query_by_topic` returned 
`ListOutput<MessageItem>`, an unbounded list whose items always carried `body` 
and `bodyEncoding`. A single topic scan could therefore put every message the 
provider found, payloads included, into one MCP response, while 
`MessageService.queryMessagesPage` — which already returns a bounded page with 
a total and a truncation flag — went unused by both tools.
    
    Both handlers now project into a new `MessageQueryOutput` record of 
`items`, `resultMayBeTruncated` and `skippedCount`, backed by 
`queryMessagesPage`. Their inputs gain `limit` (default 20, capped at 100 by 
`resultLimit()`, with `minimum: 1` enforced by the runtime schema validator) 
and `includeBody`, which defaults to false and leaves `body`, `bodyEncoding` 
and `bodyTruncated` off the item entirely rather than emitting nulls. The 
unique-key path keeps calling `queryMessageByUniqueKey [...]
    
    `includeBody` defaulting to false is a breaking output change for existing 
callers, so both catalog descriptions say so explicitly, and 
`rmq.message.query_by_offset` documents that it still returns bodies 
unconditionally.
    
    Maintainer edits on top of the contribution: 
`rmqctl/internal/catalog/catalog_gen.go` was regenerated again after the rebase 
onto trunk, which had picked up another `tool-catalog` change (#4544's 
`group.yaml` sentinel) in the meantime; the committed digest covers both.
    
    Fixes #4143
    Fixes #4147
---
 rmqctl/internal/catalog/catalog_gen.go             |  13 +-
 .../contract/message/MessageQueryByTopicInput.java |  16 +-
 .../tool/contract/message/MessageQueryInput.java   |  16 +-
 .../tool/contract/message/MessageQueryOutput.java  |  81 ++++++++++
 .../tool/contract/message/MessageTraceInput.java   |   7 +-
 .../message/MessageQueryByTopicToolHandler.java    |  16 +-
 .../handler/message/MessageQueryToolHandler.java   |  28 ++--
 .../handler/message/MessageTraceToolHandler.java   |   8 +-
 .../main/resources/tool-catalog/tools/message.yaml |  41 ++++-
 .../ops/ai/tool/MessageTraceToolHandlerTest.java   |  32 ++++
 .../ops/ai/tool/catalog/ToolCatalogTest.java       |  62 ++++++++
 .../MessageQueryByTopicToolHandlerTest.java        |  98 +++++++++---
 .../message/MessageQueryToolHandlerTest.java       | 171 +++++++++++++--------
 .../tool/service/ToolOutputSchemaContractTest.java |  15 +-
 14 files changed, 474 insertions(+), 130 deletions(-)

diff --git a/rmqctl/internal/catalog/catalog_gen.go 
b/rmqctl/internal/catalog/catalog_gen.go
index 35db62557..622d4a59d 100644
--- a/rmqctl/internal/catalog/catalog_gen.go
+++ b/rmqctl/internal/catalog/catalog_gen.go
@@ -21,7 +21,7 @@ package catalog
 var defaultDocument = Document{
        Version:              "2.0.0",
        MinimumClientVersion: "2.0.0",
-       Digest:               
"71c2cc225321b7e83243ef77b7330f25800e9623c0a110f56b27a6834a21735e",
+       Digest:               
"94c2164d0181f55aebc2f32ef1f3db2c0b92185e98ac7824e492b6f0e29014b9",
        Tools: []Tool{
                {
                        Name:                 "rmq.acl.list",
@@ -372,7 +372,7 @@ var defaultDocument = Document{
                {
                        Name:                 "rmq.message.query",
                        CLI:                  CLI{Resource: "message", Verb: 
"query"},
-                       Description:          "Query RocketMQ messages by 
identifier: the first non-empty value among msgId, uniqueKey and key (in this 
order) selects the query path, and at least one of them must be provided. The 
startTime/endTime window only applies to the uniqueKey and key paths.",
+                       Description:          "Query RocketMQ messages by 
identifier: the first non-empty value among msgId, uniqueKey and key (in this 
order) selects the query path, and at least one of them must be provided. The 
startTime/endTime window only applies to the uniqueKey and key paths. Results 
are limited to 20 by default; values above the server-side cap of 100 are 
reduced to 100. Breaking output change: message bodies are omitted by default, 
so callers that read items[].body must set includeBo [...]
                        RiskLevel:            "L1",
                        Permission:           "message:read",
                        RequiredCapabilities: []string{"MESSAGE_QUERY"},
@@ -385,6 +385,8 @@ var defaultDocument = Document{
                                        {Name: "key", Flag: "key", Description: 
"Business key (key index, 0..n messages).", Kind: StringField, MinLength: 1},
                                        {Name: "startTime", Flag: "start-time", 
Description: "Epoch milliseconds; only applies to the uniqueKey and key 
paths.", Kind: IntegerField},
                                        {Name: "endTime", Flag: "end-time", 
Description: "Epoch milliseconds; only applies to the uniqueKey and key 
paths.", Kind: IntegerField},
+                                       {Name: "limit", Flag: "limit", 
Description: "Maximum rows to return; defaults to 20 and values above 100 are 
capped at 100.", Kind: IntegerField, Minimum: 1, HasMinimum: true},
+                                       {Name: "includeBody", Flag: 
"include-body", Description: "Include body, bodyEncoding and bodyTruncated; 
defaults to false.", Kind: BooleanField},
                                },
                        },
                        ViewHint:     "table",
@@ -393,7 +395,7 @@ var defaultDocument = Document{
                {
                        Name:                 "rmq.message.query_by_topic",
                        CLI:                  CLI{Resource: "message", Verb: 
"query-by-topic"},
-                       Description:          "Query RocketMQ messages by topic 
and optional time range in one Studio Instance.",
+                       Description:          "Query RocketMQ messages by topic 
and optional time range in one Studio Instance. Results are limited to 20 by 
default; values above the server-side cap of 100 are reduced to 100. Breaking 
output change: message bodies are omitted by default, so callers that read 
items[].body must set includeBody=true.",
                        RiskLevel:            "L1",
                        Permission:           "message:read",
                        RequiredCapabilities: []string{"MESSAGE_QUERY"},
@@ -404,6 +406,8 @@ var defaultDocument = Document{
                                        {Name: "tag", Flag: "tag", Kind: 
StringField},
                                        {Name: "startTime", Flag: "start-time", 
Kind: IntegerField},
                                        {Name: "endTime", Flag: "end-time", 
Kind: IntegerField},
+                                       {Name: "limit", Flag: "limit", 
Description: "Maximum rows to return; defaults to 20 and values above 100 are 
capped at 100.", Kind: IntegerField, Minimum: 1, HasMinimum: true},
+                                       {Name: "includeBody", Flag: 
"include-body", Description: "Include body, bodyEncoding and bodyTruncated; 
defaults to false.", Kind: BooleanField},
                                },
                        },
                        ViewHint:     "table",
@@ -412,7 +416,7 @@ var defaultDocument = Document{
                {
                        Name:                 "rmq.message.query_by_offset",
                        CLI:                  CLI{Resource: "message", Verb: 
"query-by-offset"},
-                       Description:          "Fetch the message stored at one 
physical queue offset (admin queryMsgByOffset); returns empty items when the 
offset is out of range or the CommitLog entry was already cleaned. Pair it with 
the queueStats block of rmq.topic.detail.",
+                       Description:          "Fetch the message stored at one 
physical queue offset (admin queryMsgByOffset); returns empty items when the 
offset is out of range or the CommitLog entry was already cleaned. Unlike 
rmq.message.query and rmq.message.query_by_topic, this single-message tool 
always includes body, bodyEncoding and bodyTruncated and has no includeBody 
flag. Pair it with the queueStats block of rmq.topic.detail.",
                        RiskLevel:            "L1",
                        Permission:           "message:read",
                        RequiredCapabilities: []string{"MESSAGE_QUERY"},
@@ -469,6 +473,7 @@ var defaultDocument = Document{
                                        {Name: "instanceId", Flag: 
"instance-id", Description: "Studio Instance identifier.", Kind: StringField, 
Required: true, MinLength: 1},
                                        {Name: "topicName", Flag: "topic-name", 
Kind: StringField},
                                        {Name: "msgId", Flag: "msg-id", Kind: 
StringField, Required: true, MinLength: 1},
+                                       {Name: "traceTopicName", Flag: 
"trace-topic-name", Description: "Optional custom trace Topic name; blank or 
absent uses provider defaults.", Kind: StringField},
                                },
                        },
                        ViewHint: "object",
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/ops/ai/tool/contract/message/MessageQueryByTopicInput.java
 
b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/tool/contract/message/MessageQueryByTopicInput.java
index 2b4a31b72..8ed6202a2 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/ops/ai/tool/contract/message/MessageQueryByTopicInput.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/tool/contract/message/MessageQueryByTopicInput.java
@@ -21,5 +21,19 @@ public record MessageQueryByTopicInput(
         String topicName,
         String tag,
         Long startTime,
-        Long endTime) {
+        Long endTime,
+        Integer limit,
+        boolean includeBody) {
+
+    private static final int DEFAULT_LIMIT = 20;
+    private static final int MAX_LIMIT = 100;
+
+    public MessageQueryByTopicInput(String instanceId, String topicName, 
String tag,
+                                    Long startTime, Long endTime) {
+        this(instanceId, topicName, tag, startTime, endTime, null, false);
+    }
+
+    public int resultLimit() {
+        return limit == null ? DEFAULT_LIMIT : Math.min(limit, MAX_LIMIT);
+    }
 }
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/ops/ai/tool/contract/message/MessageQueryInput.java
 
b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/tool/contract/message/MessageQueryInput.java
index 5d55003a3..098892c5b 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/ops/ai/tool/contract/message/MessageQueryInput.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/tool/contract/message/MessageQueryInput.java
@@ -26,5 +26,19 @@ public record MessageQueryInput(
         String uniqueKey,
         String key,
         Long startTime,
-        Long endTime) {
+        Long endTime,
+        Integer limit,
+        boolean includeBody) {
+
+    private static final int DEFAULT_LIMIT = 20;
+    private static final int MAX_LIMIT = 100;
+
+    public MessageQueryInput(String instanceId, String topicName, String msgId,
+                             String uniqueKey, String key, Long startTime, 
Long endTime) {
+        this(instanceId, topicName, msgId, uniqueKey, key, startTime, endTime, 
null, false);
+    }
+
+    public int resultLimit() {
+        return limit == null ? DEFAULT_LIMIT : Math.min(limit, MAX_LIMIT);
+    }
 }
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/ops/ai/tool/contract/message/MessageQueryOutput.java
 
b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/tool/contract/message/MessageQueryOutput.java
new file mode 100644
index 000000000..cb3c33063
--- /dev/null
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/tool/contract/message/MessageQueryOutput.java
@@ -0,0 +1,81 @@
+/*
+ * 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 org.apache.rocketmq.studio.ops.ai.tool.contract.message;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+import org.apache.rocketmq.studio.instance.message.MessageQueryPageVO;
+import org.apache.rocketmq.studio.instance.message.MessageRecordVO;
+
+import java.util.List;
+
+public record MessageQueryOutput(
+        List<Item> items,
+        boolean resultMayBeTruncated,
+        long skippedCount) {
+
+    public static MessageQueryOutput fromPage(MessageQueryPageVO result, 
boolean includeBody) {
+        long skippedCount = Math.max(0, result.getTotal() - 
result.getItems().size());
+        return new MessageQueryOutput(
+                result.getItems().stream()
+                        .map(message -> Item.from(message, includeBody))
+                        .toList(),
+                result.isResultMayBeTruncated() || skippedCount > 0,
+                skippedCount);
+    }
+
+    public static MessageQueryOutput fromUniqueKey(
+            List<MessageRecordVO> messages, int limit, boolean includeBody) {
+        int to = Math.min(limit, messages.size());
+        long skippedCount = messages.size() - to;
+        return new MessageQueryOutput(
+                messages.subList(0, to).stream()
+                        .map(message -> Item.from(message, includeBody))
+                        .toList(),
+                skippedCount > 0,
+                skippedCount);
+    }
+
+    @JsonInclude(JsonInclude.Include.NON_NULL)
+    public record Item(
+            String msgId,
+            String topic,
+            String tag,
+            String key,
+            long storeTime,
+            String storeHost,
+            String bornHost,
+            String body,
+            String bodyEncoding,
+            Boolean bodyTruncated,
+            int size) {
+
+        static Item from(MessageRecordVO message, boolean includeBody) {
+            return new Item(
+                    message.getMsgId(),
+                    message.getTopic(),
+                    message.getTag(),
+                    message.getKey(),
+                    message.getStoreTime(),
+                    message.getStoreHost(),
+                    message.getBornHost(),
+                    includeBody ? message.getBody() : null,
+                    includeBody ? message.getBodyEncoding() : null,
+                    includeBody ? message.isBodyTruncated() : null,
+                    message.getSize());
+        }
+    }
+}
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/ops/ai/tool/contract/message/MessageTraceInput.java
 
b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/tool/contract/message/MessageTraceInput.java
index 6207c2ac9..81b3a8ea4 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/ops/ai/tool/contract/message/MessageTraceInput.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/tool/contract/message/MessageTraceInput.java
@@ -19,5 +19,10 @@ package 
org.apache.rocketmq.studio.ops.ai.tool.contract.message;
 public record MessageTraceInput(
         String instanceId,
         String topicName,
-        String msgId) {
+        String msgId,
+        String traceTopicName) {
+
+    public MessageTraceInput(String instanceId, String topicName, String 
msgId) {
+        this(instanceId, topicName, msgId, null);
+    }
 }
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/ops/ai/tool/handler/message/MessageQueryByTopicToolHandler.java
 
b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/tool/handler/message/MessageQueryByTopicToolHandler.java
index fda630c59..d3d369230 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/ops/ai/tool/handler/message/MessageQueryByTopicToolHandler.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/tool/handler/message/MessageQueryByTopicToolHandler.java
@@ -17,11 +17,10 @@
 package org.apache.rocketmq.studio.ops.ai.tool.handler.message;
 
 import org.apache.rocketmq.studio.instance.message.MessageService;
-import org.apache.rocketmq.studio.ops.ai.tool.contract.message.MessageItem;
 import org.apache.rocketmq.studio.ops.ai.tool.core.ToolExecutionContext;
 import org.apache.rocketmq.studio.ops.ai.tool.core.ToolHandler;
-import org.apache.rocketmq.studio.ops.ai.tool.contract.common.ListOutput;
 import 
org.apache.rocketmq.studio.ops.ai.tool.contract.message.MessageQueryByTopicInput;
+import 
org.apache.rocketmq.studio.ops.ai.tool.contract.message.MessageQueryOutput;
 import lombok.RequiredArgsConstructor;
 import org.springframework.stereotype.Component;
 
@@ -32,7 +31,7 @@ import org.springframework.stereotype.Component;
 @Component
 @RequiredArgsConstructor
 public class MessageQueryByTopicToolHandler
-        implements ToolHandler<MessageQueryByTopicInput, 
ListOutput<MessageItem>> {
+        implements ToolHandler<MessageQueryByTopicInput, MessageQueryOutput> {
 
     private final MessageService messageService;
 
@@ -47,13 +46,10 @@ public class MessageQueryByTopicToolHandler
     }
 
     @Override
-    public ListOutput<MessageItem> execute(
+    public MessageQueryOutput execute(
             MessageQueryByTopicInput input, ToolExecutionContext context) {
-        return new ListOutput<>(messageService.queryMessages(
-                        context.instanceId(), input.topicName(), null, 
input.tag(), null,
-                        input.startTime(), input.endTime())
-                .stream()
-                .map(MessageItem::from)
-                .toList());
+        return MessageQueryOutput.fromPage(messageService.queryMessagesPage(
+                context.instanceId(), input.topicName(), null, input.tag(), 
null,
+                input.startTime(), input.endTime(), 1, input.resultLimit()), 
input.includeBody());
     }
 }
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/ops/ai/tool/handler/message/MessageQueryToolHandler.java
 
b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/tool/handler/message/MessageQueryToolHandler.java
index 2de366a54..d918c0998 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/ops/ai/tool/handler/message/MessageQueryToolHandler.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/tool/handler/message/MessageQueryToolHandler.java
@@ -17,19 +17,15 @@
 package org.apache.rocketmq.studio.ops.ai.tool.handler.message;
 
 import org.apache.rocketmq.studio.common.exception.BusinessException;
-import org.apache.rocketmq.studio.instance.message.MessageRecordVO;
 import org.apache.rocketmq.studio.instance.message.MessageService;
-import org.apache.rocketmq.studio.ops.ai.tool.contract.common.ListOutput;
-import org.apache.rocketmq.studio.ops.ai.tool.contract.message.MessageItem;
 import 
org.apache.rocketmq.studio.ops.ai.tool.contract.message.MessageQueryInput;
+import 
org.apache.rocketmq.studio.ops.ai.tool.contract.message.MessageQueryOutput;
 import org.apache.rocketmq.studio.ops.ai.tool.core.ToolExecutionContext;
 import org.apache.rocketmq.studio.ops.ai.tool.core.ToolHandler;
 import lombok.RequiredArgsConstructor;
 import org.springframework.stereotype.Component;
 import org.springframework.util.StringUtils;
 
-import java.util.List;
-
 /**
  * Unified message query: the first non-empty identifier among msgId, 
uniqueKey and key (in this
  * order) selects the query path; the time window only applies to the 
uniqueKey and key paths.
@@ -37,7 +33,7 @@ import java.util.List;
 @Component
 @RequiredArgsConstructor
 public class MessageQueryToolHandler
-        implements ToolHandler<MessageQueryInput, ListOutput<MessageItem>> {
+        implements ToolHandler<MessageQueryInput, MessageQueryOutput> {
 
     private final MessageService messageService;
 
@@ -52,22 +48,24 @@ public class MessageQueryToolHandler
     }
 
     @Override
-    public ListOutput<MessageItem> execute(MessageQueryInput input, 
ToolExecutionContext context) {
+    public MessageQueryOutput execute(MessageQueryInput input, 
ToolExecutionContext context) {
         String instanceId = context.instanceId();
-        List<MessageRecordVO> messages;
+        int limit = input.resultLimit();
         if (StringUtils.hasText(input.msgId())) {
-            messages = messageService.queryMessages(
-                    instanceId, input.topicName(), input.msgId(), null, null, 
null, null);
+            return 
MessageQueryOutput.fromPage(messageService.queryMessagesPage(
+                    instanceId, input.topicName(), input.msgId(), null, null, 
null, null,
+                    1, limit), input.includeBody());
         } else if (StringUtils.hasText(input.uniqueKey())) {
-            messages = messageService.queryMessageByUniqueKey(
-                    instanceId, input.topicName(), input.uniqueKey(), 
input.startTime(), input.endTime());
+            return 
MessageQueryOutput.fromUniqueKey(messageService.queryMessageByUniqueKey(
+                    instanceId, input.topicName(), input.uniqueKey(), 
input.startTime(), input.endTime()),
+                    limit, input.includeBody());
         } else if (StringUtils.hasText(input.key())) {
-            messages = messageService.queryMessages(
+            return 
MessageQueryOutput.fromPage(messageService.queryMessagesPage(
                     instanceId, input.topicName(), null, null, input.key(),
-                    input.startTime(), input.endTime());
+                    input.startTime(), input.endTime(), 1, limit),
+                    input.includeBody());
         } else {
             throw new BusinessException(400, "message query requires one of: 
msgId, uniqueKey, key");
         }
-        return new 
ListOutput<>(messages.stream().map(MessageItem::from).toList());
     }
 }
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/ops/ai/tool/handler/message/MessageTraceToolHandler.java
 
b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/tool/handler/message/MessageTraceToolHandler.java
index b5ba91b40..9b7cdea33 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/ops/ai/tool/handler/message/MessageTraceToolHandler.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/tool/handler/message/MessageTraceToolHandler.java
@@ -26,6 +26,7 @@ import 
org.apache.rocketmq.studio.ops.ai.tool.contract.message.MessageTraceOutpu
 import org.apache.rocketmq.studio.ops.ai.tool.core.ToolExecutionContext;
 import org.apache.rocketmq.studio.ops.ai.tool.core.ToolHandler;
 import org.springframework.stereotype.Component;
+import org.springframework.util.StringUtils;
 
 @Component
 @RequiredArgsConstructor
@@ -48,8 +49,11 @@ public class MessageTraceToolHandler
     public MessageTraceOutput execute(
             MessageTraceInput input,
             ToolExecutionContext context) {
-        TraceRecordVO trace = messageService.getMessageTrace(
-                context.instanceId(), input.msgId(), input.topicName());
+        TraceRecordVO trace = StringUtils.hasText(input.traceTopicName())
+                ? messageService.getMessageTrace(
+                        context.instanceId(), input.msgId(), 
input.topicName(), input.traceTopicName())
+                : messageService.getMessageTrace(
+                        context.instanceId(), input.msgId(), 
input.topicName());
         return project(input.msgId(), trace);
     }
 
diff --git a/server/src/main/resources/tool-catalog/tools/message.yaml 
b/server/src/main/resources/tool-catalog/tools/message.yaml
index 7550527a7..2da865a26 100644
--- a/server/src/main/resources/tool-catalog/tools/message.yaml
+++ b/server/src/main/resources/tool-catalog/tools/message.yaml
@@ -4,7 +4,7 @@ tools:
     cli:
       resource: message
       verb: query
-    description: "Query RocketMQ messages by identifier: the first non-empty 
value among msgId, uniqueKey and key (in this order) selects the query path, 
and at least one of them must be provided. The startTime/endTime window only 
applies to the uniqueKey and key paths."
+    description: "Query RocketMQ messages by identifier: the first non-empty 
value among msgId, uniqueKey and key (in this order) selects the query path, 
and at least one of them must be provided. The startTime/endTime window only 
applies to the uniqueKey and key paths. Results are limited to 20 by default; 
values above the server-side cap of 100 are reduced to 100. Breaking output 
change: message bodies are omitted by default, so callers that read 
items[].body must set includeBody=true."
     riskLevel: L1
     permission: message:read
     requiredCapabilities:
@@ -41,10 +41,19 @@ tools:
         endTime:
           type: integer
           description: Epoch milliseconds; only applies to the uniqueKey and 
key paths.
+        limit:
+          type: integer
+          description: Maximum rows to return; defaults to 20 and values above 
100 are capped at 100.
+          minimum: 1
+        includeBody:
+          type: boolean
+          description: Include body, bodyEncoding and bodyTruncated; defaults 
to false.
     outputSchema:
       type: object
       required:
         - items
+        - resultMayBeTruncated
+        - skippedCount
       additionalProperties: false
       properties:
         items:
@@ -55,7 +64,6 @@ tools:
               - msgId
               - topic
               - storeTime
-              - bodyTruncated
               - size
             additionalProperties: false
             properties:
@@ -81,13 +89,19 @@ tools:
                 type: boolean
               size:
                 type: integer
+        resultMayBeTruncated:
+          type: boolean
+          description: True when rows were omitted by limit or the provider 
may have stopped at its own scan bound.
+        skippedCount:
+          type: integer
+          description: Rows omitted by limit from the provider-bounded result; 
more may exist when resultMayBeTruncated is true.
     viewHint: table
     deprecated: false
   - name: rmq.message.query_by_topic
     cli:
       resource: message
       verb: query-by-topic
-    description: Query RocketMQ messages by topic and optional time range in 
one Studio Instance.
+    description: "Query RocketMQ messages by topic and optional time range in 
one Studio Instance. Results are limited to 20 by default; values above the 
server-side cap of 100 are reduced to 100. Breaking output change: message 
bodies are omitted by default, so callers that read items[].body must set 
includeBody=true."
     riskLevel: L1
     permission: message:read
     requiredCapabilities:
@@ -112,10 +126,19 @@ tools:
           type: integer
         endTime:
           type: integer
+        limit:
+          type: integer
+          description: Maximum rows to return; defaults to 20 and values above 
100 are capped at 100.
+          minimum: 1
+        includeBody:
+          type: boolean
+          description: Include body, bodyEncoding and bodyTruncated; defaults 
to false.
     outputSchema:
       type: object
       required:
         - items
+        - resultMayBeTruncated
+        - skippedCount
       additionalProperties: false
       properties:
         items:
@@ -126,7 +149,6 @@ tools:
               - msgId
               - topic
               - storeTime
-              - bodyTruncated
               - size
             additionalProperties: false
             properties:
@@ -152,13 +174,19 @@ tools:
                 type: boolean
               size:
                 type: integer
+        resultMayBeTruncated:
+          type: boolean
+          description: True when rows were omitted by limit or the provider 
may have stopped at its own scan bound.
+        skippedCount:
+          type: integer
+          description: Rows omitted by limit from the provider-bounded result; 
more may exist when resultMayBeTruncated is true.
     viewHint: table
     deprecated: false
   - name: rmq.message.query_by_offset
     cli:
       resource: message
       verb: query-by-offset
-    description: "Fetch the message stored at one physical queue offset (admin 
queryMsgByOffset); returns empty items when the offset is out of range or the 
CommitLog entry was already cleaned. Pair it with the queueStats block of 
rmq.topic.detail."
+    description: "Fetch the message stored at one physical queue offset (admin 
queryMsgByOffset); returns empty items when the offset is out of range or the 
CommitLog entry was already cleaned. Unlike rmq.message.query and 
rmq.message.query_by_topic, this single-message tool always includes body, 
bodyEncoding and bodyTruncated and has no includeBody flag. Pair it with the 
queueStats block of rmq.topic.detail."
     riskLevel: L1
     permission: message:read
     requiredCapabilities:
@@ -351,6 +379,9 @@ tools:
         msgId:
           type: string
           minLength: 1
+        traceTopicName:
+          type: string
+          description: Optional custom trace Topic name; blank or absent uses 
provider defaults.
     outputSchema:
       type: object
       required:
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/ops/ai/tool/MessageTraceToolHandlerTest.java
 
b/server/src/test/java/org/apache/rocketmq/studio/ops/ai/tool/MessageTraceToolHandlerTest.java
index 6453633b6..a560a26f3 100644
--- 
a/server/src/test/java/org/apache/rocketmq/studio/ops/ai/tool/MessageTraceToolHandlerTest.java
+++ 
b/server/src/test/java/org/apache/rocketmq/studio/ops/ai/tool/MessageTraceToolHandlerTest.java
@@ -84,4 +84,36 @@ class MessageTraceToolHandlerTest {
 
         verify(messageService).getMessageTrace("instance-a", "msg-1", 
"TopicA");
     }
+
+    @Test
+    void customTraceTopicNameUsesTheExplicitServicePathTest() {
+        TraceRecordVO trace = TraceRecordVO.builder()
+                .nodes(List.of())
+                .consumerStatus(List.of())
+                .build();
+        when(messageService.getMessageTrace(
+                "instance-a", "msg-1", "TopicA", "CustomTraceTopic"))
+                .thenReturn(trace);
+
+        handler.execute(new MessageTraceInput(
+                "instance-a", "TopicA", "msg-1", "CustomTraceTopic"), 
context("instance-a"));
+
+        verify(messageService).getMessageTrace(
+                "instance-a", "msg-1", "TopicA", "CustomTraceTopic");
+    }
+
+    @Test
+    void blankTraceTopicNameKeepsTheDefaultServicePathTest() {
+        TraceRecordVO trace = TraceRecordVO.builder()
+                .nodes(List.of())
+                .consumerStatus(List.of())
+                .build();
+        when(messageService.getMessageTrace("instance-a", "msg-1", "TopicA"))
+                .thenReturn(trace);
+
+        handler.execute(new MessageTraceInput("instance-a", "TopicA", "msg-1", 
"   "),
+                context("instance-a"));
+
+        verify(messageService).getMessageTrace("instance-a", "msg-1", 
"TopicA");
+    }
 }
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/ops/ai/tool/catalog/ToolCatalogTest.java
 
b/server/src/test/java/org/apache/rocketmq/studio/ops/ai/tool/catalog/ToolCatalogTest.java
index ac3d4815b..f6178e71b 100644
--- 
a/server/src/test/java/org/apache/rocketmq/studio/ops/ai/tool/catalog/ToolCatalogTest.java
+++ 
b/server/src/test/java/org/apache/rocketmq/studio/ops/ai/tool/catalog/ToolCatalogTest.java
@@ -16,14 +16,20 @@
  */
 package org.apache.rocketmq.studio.ops.ai.tool.catalog;
 
+import org.apache.rocketmq.studio.common.config.LegacyJackson2Config;
 import org.apache.rocketmq.studio.ops.ai.tool.core.ToolDefinition;
+import org.apache.rocketmq.studio.ops.ai.tool.core.ToolExecutionException;
 import org.apache.rocketmq.studio.ops.ai.tool.core.ToolRiskLevel;
+import org.apache.rocketmq.studio.ops.ai.tool.service.ToolSchemaValidator;
 import org.junit.jupiter.api.Test;
 import org.springframework.core.io.ByteArrayResource;
 import org.springframework.core.io.DefaultResourceLoader;
 import org.springframework.core.io.Resource;
 
 import java.nio.charset.StandardCharsets;
+import java.util.List;
+import java.util.Map;
+import tools.jackson.databind.json.JsonMapper;
 
 import static org.assertj.core.api.Assertions.assertThat;
 import static org.assertj.core.api.Assertions.assertThatThrownBy;
@@ -45,6 +51,62 @@ class ToolCatalogTest {
         assertThat(catalog.find("rmq.unknown")).isEmpty();
     }
 
+    @Test
+    @SuppressWarnings("unchecked")
+    void messageQueriesAdvertiseBoundedResultsAndOptionalBodiesTest() {
+        ToolCatalog catalog = new ToolCatalog(new DefaultResourceLoader());
+        for (String toolName : new String[]{"rmq.message.query", 
"rmq.message.query_by_topic"}) {
+            ToolDefinition definition = catalog.getDefinition(toolName);
+            Map<String, Object> inputProperties =
+                    (Map<String, Object>) 
definition.inputSchema().get("properties");
+            Map<String, Object> outputProperties =
+                    (Map<String, Object>) 
definition.outputSchema().get("properties");
+
+            assertThat(inputProperties)
+                    .containsKeys("limit", "includeBody")
+                    .doesNotContainKeys("page", "pageSize");
+            assertThat((Map<String, Object>) inputProperties.get("limit"))
+                    .containsEntry("minimum", 1);
+            assertThat(outputProperties)
+                    .containsKeys("items", "resultMayBeTruncated", 
"skippedCount")
+                    .doesNotContainKeys("total", "page", "pageSize", "size");
+        }
+    }
+
+    @Test
+    @SuppressWarnings("unchecked")
+    void messageTraceAdvertisesOptionalCustomTraceTopicNameTest() {
+        Map<String, Object> inputSchema = new ToolCatalog(new 
DefaultResourceLoader())
+                .getDefinition("rmq.message.trace").inputSchema();
+        Map<String, Object> properties = (Map<String, Object>) 
inputSchema.get("properties");
+
+        
assertThat(properties).containsKey("traceTopicName").doesNotContainKey("traceTopic");
+        assertThat((Map<String, Object>) properties.get("traceTopicName"))
+                .containsEntry("type", "string");
+        assertThat((List<String>) inputSchema.get("required"))
+                .doesNotContain("traceTopicName");
+    }
+
+    @Test
+    void 
rejectsInvalidMessageQueryLimitsAndLegacyPagingThroughTheRuntimeSchemaTest() {
+        ToolCatalog catalog = new ToolCatalog(new DefaultResourceLoader());
+        ToolSchemaValidator validator = new ToolSchemaValidator(catalog,
+                new LegacyJackson2Config().jackson2ObjectMapper(), 
JsonMapper.builder().build());
+        for (String toolName : new String[]{"rmq.message.query", 
"rmq.message.query_by_topic"}) {
+            ToolDefinition definition = catalog.getDefinition(toolName);
+            for (Map<String, Object> invalid : List.<Map<String, Object>>of(
+                    Map.of("instanceId", "instance-a", "topicName", "TopicA",
+                            "limit", 0),
+                    Map.of("instanceId", "instance-a", "topicName", "TopicA",
+                            "page", Map.of("page", 1, "pageSize", 10)),
+                    Map.of("instanceId", "instance-a", "topicName", "TopicA", 
"pageSize", 10))) {
+                assertThatThrownBy(() -> validator.validateInput(definition, 
invalid))
+                        .isInstanceOfSatisfying(ToolExecutionException.class,
+                                error -> 
assertThat(error.getCode()).isEqualTo(400));
+            }
+        }
+    }
+
     @Test
     void rejectsCatalogThatDoesNotMatchItsJsonSchema() {
         Resource invalid = utf8Resource("""
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/ops/ai/tool/handler/message/MessageQueryByTopicToolHandlerTest.java
 
b/server/src/test/java/org/apache/rocketmq/studio/ops/ai/tool/handler/message/MessageQueryByTopicToolHandlerTest.java
index 7e59e36dd..b06835576 100644
--- 
a/server/src/test/java/org/apache/rocketmq/studio/ops/ai/tool/handler/message/MessageQueryByTopicToolHandlerTest.java
+++ 
b/server/src/test/java/org/apache/rocketmq/studio/ops/ai/tool/handler/message/MessageQueryByTopicToolHandlerTest.java
@@ -16,10 +16,11 @@
  */
 package org.apache.rocketmq.studio.ops.ai.tool.handler.message;
 
+import com.fasterxml.jackson.core.type.TypeReference;
+import org.apache.rocketmq.studio.common.config.LegacyJackson2Config;
+import org.apache.rocketmq.studio.instance.message.MessageQueryPageVO;
 import org.apache.rocketmq.studio.instance.message.MessageRecordVO;
 import org.apache.rocketmq.studio.instance.message.MessageService;
-import org.apache.rocketmq.studio.ops.ai.tool.contract.common.ListOutput;
-import org.apache.rocketmq.studio.ops.ai.tool.contract.message.MessageItem;
 import 
org.apache.rocketmq.studio.ops.ai.tool.contract.message.MessageQueryByTopicInput;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.extension.ExtendWith;
@@ -27,15 +28,16 @@ import org.mockito.InjectMocks;
 import org.mockito.Mock;
 import org.mockito.junit.jupiter.MockitoExtension;
 
+import java.util.Collections;
 import java.util.List;
+import java.util.Map;
 
+import static 
org.apache.rocketmq.studio.ops.ai.tool.TestToolExecutionContexts.context;
 import static org.assertj.core.api.Assertions.assertThat;
-import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.ArgumentMatchers.eq;
 import static org.mockito.ArgumentMatchers.isNull;
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
-import static 
org.apache.rocketmq.studio.ops.ai.tool.TestToolExecutionContexts.context;
 
 @ExtendWith(MockitoExtension.class)
 class MessageQueryByTopicToolHandlerTest {
@@ -47,36 +49,82 @@ class MessageQueryByTopicToolHandlerTest {
     private MessageQueryByTopicToolHandler handler;
 
     @Test
-    void executeShouldDelegateToMessageServiceAndProject() {
-        MessageRecordVO message = MessageRecordVO.builder()
-                .msgId("msg-1")
-                .topic("TopicA")
-                .storeTime(1000L)
-                .size(5)
-                .build();
-        when(messageService.queryMessages(eq("instance-a"), eq("TopicA"), 
isNull(), isNull(), isNull(), any(), any()))
-                .thenReturn(List.of(message));
+    void executeUsesDefaultLimitAndReportsSkippedRowsTest() {
+        MessageRecordVO message = message("msg-1", null, false);
+        when(messageService.queryMessagesPage(eq("instance-a"), eq("TopicA"), 
isNull(),
+                isNull(), isNull(), isNull(), isNull(), eq(1), eq(20)))
+                .thenReturn(page(Collections.nCopies(20, message), 25, 20, 
false));
 
-        ListOutput<MessageItem> result = handler.execute(
+        var result = handler.execute(
                 new MessageQueryByTopicInput("instance-a", "TopicA", null, 
null, null),
                 context("instance-a"));
 
-        assertThat(result.items()).hasSize(1);
-        MessageItem row = result.items().getFirst();
-        assertThat(row.msgId()).isEqualTo("msg-1");
-        assertThat(row.topic()).isEqualTo("TopicA");
+        assertThat(result.items()).hasSize(20);
+        assertThat(result.skippedCount()).isEqualTo(5);
+        assertThat(result.resultMayBeTruncated()).isTrue();
+        assertThat(result.items().getFirst().body()).isNull();
+        Map<String, Object> serialized = new 
LegacyJackson2Config().jackson2ObjectMapper()
+                .convertValue(result, new TypeReference<>() { });
+        assertThat(serialized)
+                .containsOnlyKeys("items", "resultMayBeTruncated", 
"skippedCount");
     }
 
     @Test
-    void executeShouldConvertNumericTimeArguments() {
-        when(messageService.queryMessages(any(), any(), any(), any(), any(), 
any(), any()))
-                .thenReturn(List.of());
+    void executeUsesCustomLimitAndIncludesBodiesTest() {
+        MessageRecordVO message = message("msg-2", "hello", false);
+        when(messageService.queryMessagesPage("instance-a", "TopicA", null,
+                "TagA", null, 1000L, 2000L, 1, 10))
+                .thenReturn(page(List.of(message), 1, 10, false));
 
-        handler.execute(
-                new MessageQueryByTopicInput("instance-a", "TopicA", null, 
1000L, 2000L),
+        var result = handler.execute(
+                new MessageQueryByTopicInput("instance-a", "TopicA", "TagA", 
1000L, 2000L,
+                        10, true),
                 context("instance-a"));
 
-        verify(messageService)
-                .queryMessages(eq("instance-a"), eq("TopicA"), isNull(), 
isNull(), isNull(), eq(1000L), eq(2000L));
+        verify(messageService).queryMessagesPage("instance-a", "TopicA", null,
+                "TagA", null, 1000L, 2000L, 1, 10);
+        assertThat(result.items()).singleElement().satisfies(row -> {
+            assertThat(row.body()).isEqualTo("hello");
+            assertThat(row.bodyEncoding()).isEqualTo("UTF-8");
+            assertThat(row.bodyTruncated()).isFalse();
+        });
+        assertThat(result.skippedCount()).isZero();
+        assertThat(result.resultMayBeTruncated()).isFalse();
+    }
+
+    @Test
+    void executeCapsRequestedLimitAtOneHundredTest() {
+        when(messageService.queryMessagesPage("instance-a", "TopicA", null,
+                null, null, null, null, 1, 100))
+                .thenReturn(page(List.of(), 0, 100, false));
+
+        handler.execute(new MessageQueryByTopicInput(
+                "instance-a", "TopicA", null, null, null, 500, false), 
context("instance-a"));
+
+        verify(messageService).queryMessagesPage("instance-a", "TopicA", null,
+                null, null, null, null, 1, 100);
+    }
+
+    private MessageRecordVO message(String msgId, String body, boolean 
bodyTruncated) {
+        return MessageRecordVO.builder()
+                .msgId(msgId)
+                .topic("TopicA")
+                .body(body)
+                .bodyEncoding(body == null ? null : "UTF-8")
+                .bodyTruncated(bodyTruncated)
+                .storeTime(1000L)
+                .size(body == null ? 0 : body.length())
+                .build();
+    }
+
+    private MessageQueryPageVO page(
+            List<MessageRecordVO> items, long total, int size, boolean 
resultMayBeTruncated) {
+        return MessageQueryPageVO.builder()
+                .items(items)
+                .total(total)
+                .page(1)
+                .size(size)
+                .resultMayBeTruncated(resultMayBeTruncated)
+                .build();
     }
 }
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/ops/ai/tool/handler/message/MessageQueryToolHandlerTest.java
 
b/server/src/test/java/org/apache/rocketmq/studio/ops/ai/tool/handler/message/MessageQueryToolHandlerTest.java
index 994bb49e3..ea4a5c6dd 100644
--- 
a/server/src/test/java/org/apache/rocketmq/studio/ops/ai/tool/handler/message/MessageQueryToolHandlerTest.java
+++ 
b/server/src/test/java/org/apache/rocketmq/studio/ops/ai/tool/handler/message/MessageQueryToolHandlerTest.java
@@ -16,12 +16,14 @@
  */
 package org.apache.rocketmq.studio.ops.ai.tool.handler.message;
 
+import com.fasterxml.jackson.core.type.TypeReference;
+import org.apache.rocketmq.studio.common.config.LegacyJackson2Config;
 import org.apache.rocketmq.studio.common.exception.BusinessException;
+import org.apache.rocketmq.studio.instance.message.MessageQueryPageVO;
 import org.apache.rocketmq.studio.instance.message.MessageRecordVO;
 import org.apache.rocketmq.studio.instance.message.MessageService;
-import org.apache.rocketmq.studio.ops.ai.tool.contract.common.ListOutput;
-import org.apache.rocketmq.studio.ops.ai.tool.contract.message.MessageItem;
 import 
org.apache.rocketmq.studio.ops.ai.tool.contract.message.MessageQueryInput;
+import org.apache.rocketmq.studio.ops.ai.tool.core.ToolExecutionContext;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.extension.ExtendWith;
 import org.junit.jupiter.params.ParameterizedTest;
@@ -30,8 +32,11 @@ import org.mockito.InjectMocks;
 import org.mockito.Mock;
 import org.mockito.junit.jupiter.MockitoExtension;
 
+import java.util.Collections;
 import java.util.List;
+import java.util.Map;
 
+import static 
org.apache.rocketmq.studio.ops.ai.tool.TestToolExecutionContexts.context;
 import static org.assertj.core.api.Assertions.assertThat;
 import static org.assertj.core.api.Assertions.assertThatThrownBy;
 import static org.mockito.ArgumentMatchers.eq;
@@ -39,7 +44,6 @@ import static org.mockito.ArgumentMatchers.isNull;
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.verifyNoInteractions;
 import static org.mockito.Mockito.when;
-import static 
org.apache.rocketmq.studio.ops.ai.tool.TestToolExecutionContexts.context;
 
 @ExtendWith(MockitoExtension.class)
 class MessageQueryToolHandlerTest {
@@ -57,84 +61,96 @@ class MessageQueryToolHandlerTest {
     }
 
     @Test
-    void msgIdWinsOverUniqueKeyAndKeyTest() {
-        MessageRecordVO message = MessageRecordVO.builder()
-                .msgId("msg-1")
-                .topic("TopicA")
-                .tag("tag1")
-                .key("key1")
-                .body("hello")
-                .bodyEncoding("UTF-8")
-                .bodyTruncated(false)
-                .storeTime(1000L)
-                .bornHost("10.0.0.1")
-                .storeHost("10.0.0.2")
-                .size(5)
-                .build();
-        when(messageService.queryMessages(
+    void msgIdWinsOverOtherIdentifiersAndUsesDefaultLimitTest() {
+        MessageRecordVO message = message("msg-1", "hello", false);
+        when(messageService.queryMessagesPage(
                 eq("instance-a"), eq("TopicA"), eq("msg-1"),
-                isNull(), isNull(), isNull(), isNull()))
-                .thenReturn(List.of(message));
-
-        ListOutput<MessageItem> result = handler.execute(
-                new MessageQueryInput("instance-a", "TopicA", "msg-1", 
"uniq-1", "order-123", 10L, 20L),
-                context("instance-a"));
-
-        assertThat(result.items()).hasSize(1);
-        MessageItem row = result.items().getFirst();
-        assertThat(row.msgId()).isEqualTo("msg-1");
-        assertThat(row.topic()).isEqualTo("TopicA");
-        assertThat(row.tag()).isEqualTo("tag1");
-        assertThat(row.storeTime()).isEqualTo(1000L);
-        assertThat(row.body()).isEqualTo("hello");
-        assertThat(row.size()).isEqualTo(5);
-        // The msgId path is a direct read: the time window never reaches the 
service.
-        verify(messageService).queryMessages(
-                eq("instance-a"), eq("TopicA"), eq("msg-1"), isNull(), 
isNull(), isNull(), isNull());
+                isNull(), isNull(), isNull(), isNull(), eq(1), eq(20)))
+                .thenReturn(page(List.of(message), 1, 20, false));
+
+        ToolExecutionContext request = ToolExecutionContext.of("instance-a", 
null, Map.of(
+                "instanceId", "instance-a", "topicName", "TopicA", "msgId", 
"msg-1",
+                "uniqueKey", "uniq-1", "key", "order-123", "startTime", 10L, 
"endTime", 20L));
+        var result = 
handler.execute(request.convertInput(handler.inputType()), request);
+
+        assertThat(result.items()).singleElement().satisfies(row -> {
+            assertThat(row.msgId()).isEqualTo("msg-1");
+            assertThat(row.topic()).isEqualTo("TopicA");
+            assertThat(row.body()).isNull();
+            assertThat(row.bodyEncoding()).isNull();
+            assertThat(row.bodyTruncated()).isNull();
+        });
+        assertThat(result.skippedCount()).isZero();
+        assertThat(result.resultMayBeTruncated()).isFalse();
+        Map<String, Object> serialized = new 
LegacyJackson2Config().jackson2ObjectMapper()
+                .convertValue(result, new TypeReference<>() { });
+        assertThat(serialized)
+                .containsOnlyKeys("items", "resultMayBeTruncated", 
"skippedCount");
+        Map<String, Object> item = new 
LegacyJackson2Config().jackson2ObjectMapper()
+                .convertValue(((List<?>) serialized.get("items")).getFirst(), 
new TypeReference<>() { });
+        assertThat(item).doesNotContainKeys("body", "bodyEncoding", 
"bodyTruncated");
+        verify(messageService).queryMessagesPage(
+                eq("instance-a"), eq("TopicA"), eq("msg-1"), isNull(), 
isNull(), isNull(), isNull(),
+                eq(1), eq(20));
     }
 
     @Test
     void uniqueKeyPathPassesTimeWindowTest() {
-        MessageRecordVO message = MessageRecordVO.builder()
-                .msgId("msg-2")
-                .topic("TopicA")
-                .storeTime(2000L)
-                .size(7)
-                .build();
         when(messageService.queryMessageByUniqueKey("instance-a", "TopicA", 
"uniq-1", 1000L, 2000L))
-                .thenReturn(List.of(message));
+                .thenReturn(List.of(message("msg-2", "private", false)));
 
-        ListOutput<MessageItem> result = handler.execute(
-                new MessageQueryInput("instance-a", "TopicA", null, "uniq-1", 
"order-123", 1000L, 2000L),
+        var result = handler.execute(
+                new MessageQueryInput("instance-a", "TopicA", null, "uniq-1", 
"order-123",
+                        1000L, 2000L),
                 context("instance-a"));
 
-        assertThat(result.items()).hasSize(1);
-        assertThat(result.items().getFirst().msgId()).isEqualTo("msg-2");
+        assertThat(result.items()).singleElement().satisfies(row -> {
+            assertThat(row.msgId()).isEqualTo("msg-2");
+            assertThat(row.body()).isNull();
+        });
+        assertThat(result.skippedCount()).isZero();
+        assertThat(result.resultMayBeTruncated()).isFalse();
         verify(messageService).queryMessageByUniqueKey("instance-a", "TopicA", 
"uniq-1", 1000L, 2000L);
     }
 
     @Test
-    void keyPathPassesTimeWindowTest() {
-        MessageRecordVO message = MessageRecordVO.builder()
-                .msgId("msg-3")
-                .topic("TopicA")
-                .key("order-123")
-                .storeTime(1000L)
-                .size(5)
-                .build();
-        when(messageService.queryMessages(
-                eq("instance-a"), eq("TopicA"), isNull(), isNull(),
-                eq("order-123"), eq(1000L), eq(2000L)))
-                .thenReturn(List.of(message));
+    void keyPathCapsLimitAndIncludesBodiesWhenRequestedTest() {
+        MessageRecordVO message = message("msg-3", "hello", true);
+        when(messageService.queryMessagesPage("instance-a", "TopicA", null, 
null,
+                "order-123", 1000L, 2000L, 1, 100))
+                .thenReturn(page(Collections.nCopies(100, message), 150, 100, 
false));
+        ToolExecutionContext request = ToolExecutionContext.of("instance-a", 
null, Map.of(
+                "instanceId", "instance-a", "topicName", "TopicA", "key", 
"order-123",
+                "startTime", 1000L, "endTime", 2000L, "limit", 500, 
"includeBody", true));
+
+        var result = 
handler.execute(request.convertInput(handler.inputType()), request);
+
+        assertThat(result.items()).hasSize(100);
+        assertThat(result.skippedCount()).isEqualTo(50);
+        assertThat(result.resultMayBeTruncated()).isTrue();
+        assertThat(result.items().getFirst()).satisfies(row -> {
+            assertThat(row.body()).isEqualTo("hello");
+            assertThat(row.bodyEncoding()).isEqualTo("UTF-8");
+            assertThat(row.bodyTruncated()).isTrue();
+        });
+        verify(messageService).queryMessagesPage("instance-a", "TopicA", null, 
null,
+                "order-123", 1000L, 2000L, 1, 100);
+    }
 
-        ListOutput<MessageItem> result = handler.execute(
-                new MessageQueryInput("instance-a", "TopicA", null, null, 
"order-123", 1000L, 2000L),
+    @Test
+    void providerTruncationIsPreservedWithoutLocallySkippedRowsTest() {
+        MessageRecordVO message = message("msg-4", null, false);
+        when(messageService.queryMessagesPage("instance-a", "TopicA", null, 
null,
+                "order-123", null, null, 1, 10))
+                .thenReturn(page(List.of(message), 1, 10, true));
+
+        var result = handler.execute(new MessageQueryInput(
+                "instance-a", "TopicA", null, null, "order-123", null, null, 
10, false),
                 context("instance-a"));
 
         assertThat(result.items()).hasSize(1);
-        MessageItem row = result.items().getFirst();
-        assertThat(row.msgId()).isEqualTo("msg-3");
-        assertThat(row.key()).isEqualTo("order-123");
+        assertThat(result.skippedCount()).isZero();
+        assertThat(result.resultMayBeTruncated()).isTrue();
     }
 
     @ParameterizedTest
@@ -149,4 +165,31 @@ class MessageQueryToolHandlerTest {
 
         verifyNoInteractions(messageService);
     }
+
+    private MessageRecordVO message(String msgId, String body, boolean 
bodyTruncated) {
+        return MessageRecordVO.builder()
+                .msgId(msgId)
+                .topic("TopicA")
+                .tag("tag1")
+                .key("key1")
+                .body(body)
+                .bodyEncoding(body == null ? null : "UTF-8")
+                .bodyTruncated(bodyTruncated)
+                .storeTime(1000L)
+                .bornHost("10.0.0.1")
+                .storeHost("10.0.0.2")
+                .size(body == null ? 0 : body.length())
+                .build();
+    }
+
+    private MessageQueryPageVO page(
+            List<MessageRecordVO> items, long total, int size, boolean 
resultMayBeTruncated) {
+        return MessageQueryPageVO.builder()
+                .items(items)
+                .total(total)
+                .page(1)
+                .size(size)
+                .resultMayBeTruncated(resultMayBeTruncated)
+                .build();
+    }
 }
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 17b7a9610..d72960cc6 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
@@ -42,6 +42,7 @@ 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.MessageQueryOutput;
 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;
@@ -285,8 +286,18 @@ class ToolOutputSchemaContractTest {
         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))));
+        MessageQueryOutput.Item withoutBody = new MessageQueryOutput.Item(
+                "MSG-1", "orders", "tagA", "keyA", TIMESTAMP,
+                "127.0.0.1:10911", "127.0.0.1:50000", null, null, null, 5);
+        MessageQueryOutput.Item withBody = new MessageQueryOutput.Item(
+                "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 MessageQueryOutput(List.of(withoutBody), false, 0),
+                new MessageQueryOutput(List.of(withBody), false, 0)));
+        samples.put("rmq.message.query_by_topic", List.of(
+                new MessageQueryOutput(List.of(withoutBody), true, 199),
+                new MessageQueryOutput(List.of(withBody), true, 199)));
         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(

Reply via email to