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 b205d5f4f fix(rmqctl): three CLI defects in nested cells,
confirm-token replay and stdio error frames (#5643)
b205d5f4f is described below
commit b205d5f4fa9b78cf912fc389074129c3d7d394b5
Author: Apulupie <[email protected]>
AuthorDate: Sat Oct 10 14:38:31 2026 +0800
fix(rmqctl): three CLI defects in nested cells, confirm-token replay and
stdio error frames (#5643)
Three rmqctl defects.
- Nested objects and arrays in table cells were rendered with Go's
`map[k:v]` syntax instead of the JSON
the server sent; `stringify` is the single funnel, so one change covers
every column. (#5643)
- A confirm token could be replayed with an auto-filled timestamp: the
signing side does not include
`timestamp` in `CONTROL_FIELDS` while the filling side refreshes it per
call, so the guard now
rejects that combination. (#5654)
- The stdio proxy's fallback error frame dropped the underlying cause; it
now reuses the existing
`normalizeCLIError` / `safeStdioDiagnostic` path. (#5656)
`go test ./...` green across all seven rmqctl packages, `make fmt` clean.
Rebased onto today's
#6057 (`fix(rmqctl): restore numeric types before YAML encoding`), which
the yaml golden test needs.
Folded in #5654 and #5656 (same author).
---
rmqctl/cmd/catalog.go | 33 ++++++-
rmqctl/cmd/catalog_instance_test.go | 68 ++++++++++++++
rmqctl/cmd/mcp_stdio.go | 23 +++++
rmqctl/cmd/mcp_stdio_error_test.go | 166 ++++++++++++++++++++++++++++++++++
rmqctl/internal/output/output.go | 9 ++
rmqctl/internal/output/output_test.go | 31 +++++++
6 files changed, 327 insertions(+), 3 deletions(-)
diff --git a/rmqctl/cmd/catalog.go b/rmqctl/cmd/catalog.go
index 0f230cb51..7f1dddb11 100644
--- a/rmqctl/cmd/catalog.go
+++ b/rmqctl/cmd/catalog.go
@@ -192,7 +192,7 @@ func runTool(
// Fill client-side defaults (x-client-default: NOW) before required
// validation and argument assembly so a schema-required field with a
// client default is self-consistent. Explicit flags always win.
- applyClientDefaults(tool, arguments)
+ filled := applyClientDefaults(tool, arguments)
// Pass the explicit --instance-id value through to tools whose schema
// declares the instance identifier (decision 7: pass-through of the
// caller's explicit value, not a default injection). Platform-level
@@ -203,6 +203,9 @@ func runTool(
if err := validateSchemaArguments(tool, tool.InputSchema, arguments);
err != nil {
return err
}
+ if err := rejectReplayWithAutoFilledDefaults(arguments, filled); err !=
nil {
+ return err
+ }
if err := confirmRisk(cmd, runtime, tool, arguments); err != nil {
return err
@@ -264,8 +267,10 @@ var nowMillis = func() int64 { return
time.Now().UnixMilli() }
// Filling happens before required validation so required fields with a client
// default (e.g. group reset-offset --timestamp) validate cleanly. The catalog
// generator rejects x-client-default on nested properties, so only top-level
-// fields need to be considered.
-func applyClientDefaults(tool toolcatalog.Tool, arguments map[string]any) {
+// fields need to be considered. The filled fields are returned so a caller can
+// tell an auto-filled value from an explicitly supplied one.
+func applyClientDefaults(tool toolcatalog.Tool, arguments map[string]any)
[]toolcatalog.Field {
+ var filled []toolcatalog.Field
for _, field := range tool.InputSchema.Fields {
if field.ClientDefault != toolcatalog.ClientDefaultNow {
continue
@@ -274,7 +279,29 @@ func applyClientDefaults(tool toolcatalog.Tool, arguments
map[string]any) {
continue
}
arguments[field.Name] = nowMillis()
+ filled = append(filled, field)
+ }
+ return filled
+}
+
+// rejectReplayWithAutoFilledDefaults refuses a --confirm-token replay whose
+// previewed input cannot be reproduced. The server signs the previewed
+// business input into the token (the timestamp of a group reset-offset
+// preview included), while an x-client-default: NOW field is filled on every
+// invocation — so a replay that omits the flag carries a fresh value, never
+// matches the preview, and is answered with a mismatch error whose hint
+// ("without changing the operation input") the caller did obey. Naming the
+// flag to pin turns that dead end into an instruction.
+func rejectReplayWithAutoFilledDefaults(arguments map[string]any, filled
[]toolcatalog.Field) error {
+ if len(filled) == 0 || !argumentPresent(arguments, "confirm_token") {
+ return nil
}
+ field := filled[0]
+ return types.NewCLIError(types.CodeInvalidArgument,
+ fmt.Sprintf("--confirm-token cannot replay an auto-filled
--%s", field.Flag),
+ fmt.Sprintf("The preview signed its own --%s into the token,
and this call filled a new value, so the server "+
+ "would reject it as a preview mismatch. Pin the
previewed value with --%s <value from the preview plan>, or "+
+ "drop --confirm-token to let --yes run the preview and
the call together.", field.Flag, field.Flag))
}
func schemaFlagUsage(field toolcatalog.Field) string {
diff --git a/rmqctl/cmd/catalog_instance_test.go
b/rmqctl/cmd/catalog_instance_test.go
index 8fbc3a6ca..e8fdbb59a 100644
--- a/rmqctl/cmd/catalog_instance_test.go
+++ b/rmqctl/cmd/catalog_instance_test.go
@@ -19,8 +19,10 @@ package cmd
import (
"bytes"
"encoding/json"
+ "errors"
"net/http"
"net/http/httptest"
+ "strings"
"testing"
"time"
@@ -209,3 +211,69 @@ func TestClientDefaultNowSatisfiesRequiredValidation(t
*testing.T) {
t.Fatalf("arguments = %#v, want the filled timestamp",
request.Arguments)
}
}
+
+func syntheticConfirmedResetTool() toolcatalog.Tool {
+ return toolcatalog.Tool{
+ Name: "rmq.synthetic.confirmed-reset",
+ CLI: toolcatalog.CLI{Resource:
"synthetic-confirmed-reset", Verb: "run"},
+ Description: "Synthetic L2 tool combining an
x-client-default NOW field with the two-phase handshake.",
+ RiskLevel: "L2",
+ Permission: "synthetic:write",
+ RequiredCapabilities: []string{},
+ InputSchema: toolcatalog.InputSchema{
+ Fields: []toolcatalog.Field{
+ {Name: "instanceId", Flag: "instance-id", Kind:
toolcatalog.StringField, Required: true, MinLength: 1},
+ {Name: "groupName", Flag: "group-name", Kind:
toolcatalog.StringField, Required: true, MinLength: 1},
+ {Name: "timestamp", Flag: "timestamp", Kind:
toolcatalog.IntegerField, Required: true, ClientDefault:
toolcatalog.ClientDefaultNow},
+ {Name: "dry_run", Flag: "dry-run", Kind:
toolcatalog.BooleanField},
+ {Name: "confirm_token", Flag: "confirm-token",
Kind: toolcatalog.StringField, MinLength: 1},
+ },
+ },
+ ViewHint: "object",
+ }
+}
+
+// TestConfirmTokenReplayWithAutoFilledTimestampIsRefused verifies the
+// two-phase handshake against the server's signing rules: the token signs the
+// previewed business input, including the timestamp of a group reset-offset
+// preview, while an x-client-default: NOW field is filled on every invocation.
+// A replay that carries a --confirm-token but omits the flag therefore cannot
+// ever match its preview, so the CLI refuses it up front and names the flag to
+// pin instead of sending a call the server is guaranteed to answer with a
+// preview-mismatch error.
+func TestConfirmTokenReplayWithAutoFilledTimestampIsRefused(t *testing.T) {
+ const pinnedNow = int64(1757600000000)
+ original := nowMillis
+ nowMillis = func() int64 { return pinnedNow }
+ defer func() { nowMillis = original }()
+
+ request, stderr, err := executeSyntheticTool(t,
syntheticConfirmedResetTool(),
+ "--group-name", "gid-orders", "--confirm-token",
"server-issued", "--yes")
+ if err == nil {
+ t.Fatalf("a confirm-token replay with an auto-filled timestamp
must be refused, request=%#v", request)
+ }
+ var cliError *types.CLIError
+ if !errors.As(err, &cliError) || cliError.Code !=
types.CodeInvalidArgument {
+ t.Fatalf("err = %v, want INVALID_ARGUMENT", err)
+ }
+ if !strings.Contains(cliError.Hint, "--timestamp") {
+ t.Fatalf("hint = %q, want it to name the flag to pin",
cliError.Hint)
+ }
+ if request.Name != "" {
+ t.Fatalf("refused replay must not reach the server, observed
%q, stderr=%s", request.Name, stderr)
+ }
+}
+
+// TestExplicitTimestampStillReplaysConfirmToken verifies the refusal is
+// specific to the auto-filled value: pinning the previewed timestamp keeps the
+// token usable, which is the path the hint points at.
+func TestExplicitTimestampStillReplaysConfirmToken(t *testing.T) {
+ request, stderr, err := executeSyntheticTool(t,
syntheticConfirmedResetTool(),
+ "--group-name", "gid-orders", "--timestamp", "1791394886405",
"--confirm-token", "server-issued", "--yes")
+ if err != nil {
+ t.Fatalf("an explicit timestamp must keep the token usable: %v,
stderr=%s", err, stderr)
+ }
+ if request.Arguments["confirm_token"] != "server-issued" {
+ t.Fatalf("arguments = %#v, want the confirm token to be sent",
request.Arguments)
+ }
+}
diff --git a/rmqctl/cmd/mcp_stdio.go b/rmqctl/cmd/mcp_stdio.go
index f5f96d5b4..087197e36 100644
--- a/rmqctl/cmd/mcp_stdio.go
+++ b/rmqctl/cmd/mcp_stdio.go
@@ -306,6 +306,29 @@ func writeStdioError(out io.Writer, errOut io.Writer,
requestLine string, callEr
Hint: hint,
},
}
+ } else {
+ // Connection refusals, TLS failures, timeouts and
protocol-decode errors carry no HTTP
+ // status, but the caller still needs the cause: an MCP client
that only receives
+ // "stdio proxy call failed" cannot tell the user (or the
model) anything but "go look at
+ // the logs". normalizeCLIError classifies them the same way
the CLI's own error path
+ // does, and the sanitized text travels in both message and
data so the frame is
+ // actionable on its own.
+ cliError := normalizeCLIError(callErr)
+ code := safeStdioDiagnostic(cliError.Code)
+ message := safeStdioDiagnostic(cliError.Message)
+ hint := safeStdioDiagnostic(cliError.Hint)
+ if message == "" {
+ message = "stdio proxy call failed"
+ }
+ rpcError = jsonRPCError{
+ Code: jsonrpcServerErrorCode,
+ Message: message,
+ Data: &jsonRPCErrorData{
+ Code: code,
+ Message: message,
+ Hint: hint,
+ },
+ }
}
errorPayload := jsonRPCErrorPayload{
JSONRPC: "2.0",
diff --git a/rmqctl/cmd/mcp_stdio_error_test.go
b/rmqctl/cmd/mcp_stdio_error_test.go
new file mode 100644
index 000000000..c8ac48bef
--- /dev/null
+++ b/rmqctl/cmd/mcp_stdio_error_test.go
@@ -0,0 +1,166 @@
+/*
+ * 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 cmd
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "net"
+ "strings"
+ "testing"
+
+ "github.com/apache/rocketmq-dashboard/rmqctl/internal/studio"
+)
+
+func decodeStdioErrorFrame(t *testing.T, out *bytes.Buffer)
jsonRPCErrorPayload {
+ t.Helper()
+ frameLine := strings.TrimSpace(out.String())
+ if frameLine == "" {
+ t.Fatal("no error frame was written to stdout")
+ }
+ if strings.Contains(frameLine, "\n") {
+ t.Fatalf("stdout carries more than one line: %q", frameLine)
+ }
+ var payload jsonRPCErrorPayload
+ if err := json.Unmarshal([]byte(frameLine), &payload); err != nil {
+ t.Fatalf("stdout frame is not JSON: %v (%q)", err, frameLine)
+ }
+ return payload
+}
+
+// TestStdioErrorFrameKeepsTheFailureCause verifies that a call failure without
+// an HTTP status still tells the MCP client what went wrong: the classified
+// code, the sanitized cause and a hint travel in the frame's data, instead of
+// collapsing into a bare "stdio proxy call failed".
+func TestStdioErrorFrameKeepsTheFailureCause(t *testing.T) {
+ connectionRefused := &net.OpError{Op: "dial", Net: "tcp", Err:
errors.New("connect: connection refused")}
+ cases := []struct {
+ name string
+ callErr error
+ wantCode string
+ wantMessage string
+ }{
+ {
+ name: "transport failure",
+ callErr: fmt.Errorf("failed to send request: %w",
connectionRefused),
+ wantCode: "UNAVAILABLE",
+ wantMessage: "connection refused",
+ },
+ {
+ name: "deadline exceeded",
+ callErr: fmt.Errorf("mcp call: %w",
context.DeadlineExceeded),
+ wantCode: "TIMEOUT",
+ wantMessage: "deadline exceeded",
+ },
+ {
+ name: "protocol decode failure",
+ callErr: errors.New("decode MCP JSON-RPC response:
unexpected end of JSON input"),
+ wantCode: "COMMAND_FAILED",
+ wantMessage: "unexpected end of JSON input",
+ },
+ }
+ for _, testCase := range cases {
+ t.Run(testCase.name, func(t *testing.T) {
+ out := &bytes.Buffer{}
+ errOut := &bytes.Buffer{}
+ writeStdioError(out, errOut,
`{"jsonrpc":"2.0","id":7,"method":"tools/call"}`, testCase.callErr)
+
+ frame := decodeStdioErrorFrame(t, out)
+ if frame.Error.Code != jsonrpcServerErrorCode {
+ t.Fatalf("frame code = %d, want %d",
frame.Error.Code, jsonrpcServerErrorCode)
+ }
+ if frame.Error.Data == nil {
+ t.Fatal("error frame carries no data payload")
+ }
+ if frame.Error.Data.Code != testCase.wantCode {
+ t.Fatalf("data.code = %q, want %q",
frame.Error.Data.Code, testCase.wantCode)
+ }
+ if !strings.Contains(frame.Error.Message,
testCase.wantMessage) {
+ t.Fatalf("message = %q, want it to contain %q",
frame.Error.Message, testCase.wantMessage)
+ }
+ if !strings.Contains(frame.Error.Data.Message,
testCase.wantMessage) {
+ t.Fatalf("data.message = %q, want it to contain
%q", frame.Error.Data.Message, testCase.wantMessage)
+ }
+ if frame.Error.Data.Hint == "" {
+ t.Fatal("data.hint is empty, so the caller has
nothing to act on")
+ }
+ if !strings.Contains(errOut.String(),
testCase.wantMessage) {
+ t.Fatalf("stderr = %q, want the cause to stay
there too", errOut.String())
+ }
+ })
+ }
+}
+
+// TestStdioErrorFrameKeepsTheHTTPStatusShape pins the branch that already
+// worked: a structured MCP HTTP error keeps its own code, message and hint.
+func TestStdioErrorFrameKeepsTheHTTPStatusShape(t *testing.T) {
+ out := &bytes.Buffer{}
+ errOut := &bytes.Buffer{}
+ writeStdioError(out, errOut,
`{"jsonrpc":"2.0","id":"srv-1","method":"tools/call"}`,
+ &studio.MCPHTTPStatusError{StatusCode: 403, Code: "FORBIDDEN",
Message: "instance mismatch", Hint: "check the instance id"})
+
+ frame := decodeStdioErrorFrame(t, out)
+ if frame.Error.Code != jsonrpcServerErrorCode {
+ t.Fatalf("frame code = %d, want %d", frame.Error.Code,
jsonrpcServerErrorCode)
+ }
+ if frame.Error.Data == nil || frame.Error.Data.Code != "FORBIDDEN" {
+ t.Fatalf("data = %#v, want the server's FORBIDDEN code",
frame.Error.Data)
+ }
+ if frame.Error.Message != "instance mismatch" || frame.Error.Data.Hint
!= "check the instance id" {
+ t.Fatalf("frame = %#v, want the server's message and hint",
frame.Error)
+ }
+}
+
+// TestStdioErrorFrameSanitizesAndCapsTheCause verifies the frame stays a
+// single line and within the diagnostic cap even for a multi-line, oversized
+// failure text.
+func TestStdioErrorFrameSanitizesAndCapsTheCause(t *testing.T) {
+ out := &bytes.Buffer{}
+ errOut := &bytes.Buffer{}
+ writeStdioError(out, errOut,
`{"jsonrpc":"2.0","id":1,"method":"tools/call"}`,
+ fmt.Errorf("first line\r\nsecond\tline %s", strings.Repeat("x",
4*stdioDiagnosticLimit)))
+
+ frame := decodeStdioErrorFrame(t, out)
+ if len(frame.Error.Message) > stdioDiagnosticLimit {
+ t.Fatalf("message length = %d, want at most %d",
len(frame.Error.Message), stdioDiagnosticLimit)
+ }
+ if strings.ContainsAny(frame.Error.Message, "\r\n\t") {
+ t.Fatalf("message = %q, want control characters flattened",
frame.Error.Message)
+ }
+ if !strings.Contains(frame.Error.Message, "first line") ||
!strings.Contains(frame.Error.Message, "second line") {
+ t.Fatalf("message = %q, want the sanitized cause",
frame.Error.Message)
+ }
+}
+
+// TestStdioErrorFrameSkipsUnparsableRequests pins the deliberate part of the
+// current behaviour: without a request id there is nothing a JSON-RPC error
+// frame could address, so only stderr carries the diagnostic.
+func TestStdioErrorFrameSkipsUnparsableRequests(t *testing.T) {
+ out := &bytes.Buffer{}
+ errOut := &bytes.Buffer{}
+ writeStdioError(out, errOut, "not json", errors.New("connect:
connection refused"))
+
+ if out.Len() != 0 {
+ t.Fatalf("stdout = %q, want no frame for an unparsable
request", out.String())
+ }
+ if !strings.Contains(errOut.String(), "connection refused") {
+ t.Fatalf("stderr = %q, want the diagnostic", errOut.String())
+ }
+}
diff --git a/rmqctl/internal/output/output.go b/rmqctl/internal/output/output.go
index 90ec62bb6..4860c933c 100644
--- a/rmqctl/internal/output/output.go
+++ b/rmqctl/internal/output/output.go
@@ -213,5 +213,14 @@ func stringify(value any) string {
if f, ok := value.(float64); ok {
return strconv.FormatFloat(f, 'f', -1, 64)
}
+ // Nested objects and arrays decoded from the tool output are JSON to
begin with;
+ // render them as JSON instead of Go's default map/slice syntax
("map[k:v]"),
+ // which is noisy and not what the server sent.
+ switch value.(type) {
+ case map[string]any, []any:
+ if encoded, err := json.Marshal(value); err == nil {
+ return string(encoded)
+ }
+ }
return fmt.Sprint(value)
}
diff --git a/rmqctl/internal/output/output_test.go
b/rmqctl/internal/output/output_test.go
index e6f9b5e13..28e517647 100644
--- a/rmqctl/internal/output/output_test.go
+++ b/rmqctl/internal/output/output_test.go
@@ -97,3 +97,34 @@ func TestRowsEscapesControlCharactersInCellValues(t
*testing.T) {
t.Fatalf("data line does not contain escaped value: %q", data)
}
}
+
+func TestRowsRendersNestedValuesAsJSON(t *testing.T) {
+ buf := &bytes.Buffer{}
+ rows := []map[string]any{
+ {
+ "name": "group-a",
+ "quota": map[string]any{"max": float64(10), "used":
float64(3)},
+ "tags": []any{"a", "b"},
+ },
+ }
+ columns := []Column{
+ {Header: "NAME", Key: "name"},
+ {Header: "QUOTA", Key: "quota"},
+ {Header: "TAGS", Key: "tags"},
+ }
+ if err := Rows(buf, rows, columns); err != nil {
+ t.Fatal(err)
+ }
+ data := strings.Split(strings.TrimSuffix(buf.String(), "\n"), "\n")[1]
+ // Go's default map/slice formatting is noise ("map[max:10 used:3]"),
and nested
+ // values are JSON to begin with, so the cell should carry the JSON
form instead.
+ if strings.Contains(data, "map[") || strings.Contains(data, "[a b]") {
+ t.Fatalf("data line renders Go syntax for nested values: %q",
data)
+ }
+ if !strings.Contains(data, `"max":10`) {
+ t.Fatalf("data line does not render the nested object as JSON:
%q", data)
+ }
+ if !strings.Contains(data, `["a","b"]`) {
+ t.Fatalf("data line does not render the nested array as JSON:
%q", data)
+ }
+}