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 484d02fb7 fix(rmqctl): keep integer literals in studio payloads and
name rejected MCP frames (#5262)
484d02fb7 is described below
commit 484d02fb7b8630d75fef30d2a02a6be60c9dd64e
Author: Apulupie <[email protected]>
AuthorDate: Fri Oct 9 17:58:06 2026 +0800
fix(rmqctl): keep integer literals in studio payloads and name rejected MCP
frames (#5262)
Two defects in rmqctl's studio passthrough.
- `request()` decoded the envelope's `data` section with `json.Unmarshal`,
which folds every JSON
number into a `float64`: 64-bit offsets and timestamps lose precision and
large ids print in
scientific notation, in the table/json/yaml output and in mutation
summaries alike. It now decodes
through a `json.Decoder` with `UseNumber()`, so numbers keep their
literal form. (#5262)
- `decodeMCPMessage` answered "missing method" for a JSON-RPC response
frame (an `id` with no
`method`), which reads as a malformed request. Response frames are now
named as such together with
the reason this transport rejects them; frames with neither `id` nor
`method` keep the old error,
and the request/notification paths are untouched. (#5273)
`go test ./internal/studio/...` green, `make fmt` and `go vet` clean.
Folded in #5273 (same author, same package).
---
rmqctl/internal/studio/client.go | 7 ++++-
rmqctl/internal/studio/client_test.go | 41 +++++++++++++++++++++++++++++-
rmqctl/internal/studio/mcp_message.go | 8 ++++++
rmqctl/internal/studio/mcp_message_test.go | 30 ++++++++++++++++++++++
4 files changed, 84 insertions(+), 2 deletions(-)
diff --git a/rmqctl/internal/studio/client.go b/rmqctl/internal/studio/client.go
index b459fc78d..cc5febfd8 100644
--- a/rmqctl/internal/studio/client.go
+++ b/rmqctl/internal/studio/client.go
@@ -158,7 +158,12 @@ func (c Client) request(ctx context.Context, target
Target, method string, path
if !hasJSONData(envelope.Data) {
return nil
}
- if err := json.Unmarshal(envelope.Data, out); err != nil {
+ // Passthrough commands decode into an `any`, where encoding/json would
represent every
+ // number as float64 and silently corrupt int64 values beyond 2^53
(RocketMQ offsets and
+ // timestamps). UseNumber keeps the literal; decoding into typed struct
fields is unaffected.
+ decoder := json.NewDecoder(bytes.NewReader(envelope.Data))
+ decoder.UseNumber()
+ if err := decoder.Decode(out); err != nil {
return fmt.Errorf("invalid studio data: %w", err)
}
return nil
diff --git a/rmqctl/internal/studio/client_test.go
b/rmqctl/internal/studio/client_test.go
index 7bc6fd464..4334d6d10 100644
--- a/rmqctl/internal/studio/client_test.go
+++ b/rmqctl/internal/studio/client_test.go
@@ -16,7 +16,14 @@
*/
package studio
-import "testing"
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+)
// TestValidateServerSchemeAdaptiveHTTP verifies the adaptive scheme policy:
// HTTPS is always accepted, plain HTTP is accepted for loopback / RFC1918
@@ -76,3 +83,35 @@ func TestIsPrivateHost(t *testing.T) {
}
}
}
+
+// The passthrough commands decode the studio payload into an `any`, where
encoding/json
+// would represent every number as float64 and silently corrupt int64 values
beyond 2^53
+// (RocketMQ offsets and timestamps). The client must keep the literal via
json.Number.
+func TestRequestPreservesLargeIntegersInPassthroughPayload(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w
http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ _, _ =
w.Write([]byte(`{"code":200,"message":"ok","data":{"items":[{"msgOffset":9007199254740993}]}}`))
+ }))
+ defer server.Close()
+
+ client := NewClient(http.DefaultClient)
+ target := Target{
+ Server: server.URL,
+ InstanceID: "instance-1",
+ Credential: Credential{AccessKey: "ak", SecretKey: "sk"},
+ Timeout: DefaultTimeout,
+ }
+
+ var payload any
+ if err := client.request(context.Background(), target, http.MethodGet,
"/api/brokers", nil, &payload); err != nil {
+ t.Fatalf("request returned error: %v", err)
+ }
+
+ encoded, err := json.Marshal(payload)
+ if err != nil {
+ t.Fatalf("marshal payload: %v", err)
+ }
+ if !strings.Contains(string(encoded), "9007199254740993") {
+ t.Fatalf("payload lost integer precision: %s", encoded)
+ }
+}
diff --git a/rmqctl/internal/studio/mcp_message.go
b/rmqctl/internal/studio/mcp_message.go
index 49bbeccf0..9acefefc2 100644
--- a/rmqctl/internal/studio/mcp_message.go
+++ b/rmqctl/internal/studio/mcp_message.go
@@ -89,6 +89,14 @@ func decodeMCPMessage(payload json.RawMessage)
(decodedMCPMessage, error) {
}
return decodedMCPMessage{notification: ¬ification}, nil
}
+ if hasID {
+ // A frame with an id but no method is a JSON-RPC response,
which the stdio
+ // proxy never originates. Name it explicitly instead of the
misleading
+ // "missing method" (see the review on #4637).
+ return decodedMCPMessage{}, fmt.Errorf(
+ "invalid MCP JSON-RPC message: response frame (id
without method); " +
+ "this transport only forwards client-originated
requests and notifications")
+ }
return decodedMCPMessage{}, fmt.Errorf("invalid MCP JSON-RPC message:
missing method")
}
diff --git a/rmqctl/internal/studio/mcp_message_test.go
b/rmqctl/internal/studio/mcp_message_test.go
index 36341238b..aee3b0542 100644
--- a/rmqctl/internal/studio/mcp_message_test.go
+++ b/rmqctl/internal/studio/mcp_message_test.go
@@ -18,6 +18,7 @@ package studio
import (
"encoding/json"
+ "strings"
"testing"
)
@@ -123,3 +124,32 @@ func TestBindToolInstanceIgnoresNonToolCallMessages(t
*testing.T) {
t.Fatalf("initialize payload was modified: %s", bound)
}
}
+
+// A JSON-RPC response (id + result/error, no method) used to fall through to
the
+// generic "missing method" error, which tells the caller nothing about what
was
+// actually wrong. See the review on #4637.
+func TestDecodeMCPMessageRejectsResponseFrameWithExplicitError(t *testing.T) {
+ _, err :=
decodeMCPMessage([]byte(`{"jsonrpc":"2.0","id":7,"result":{"ok":true}}`))
+ if err == nil {
+ t.Fatal("expected an error for a response frame")
+ }
+ if !strings.Contains(err.Error(), "response") {
+ t.Fatalf("expected an explicit response-frame error, got: %v",
err)
+ }
+ if strings.Contains(err.Error(), "missing method") {
+ t.Fatalf("the misleading missing-method error is still used:
%v", err)
+ }
+
+ _, err =
decodeMCPMessage([]byte(`{"jsonrpc":"2.0","id":8,"error":{"code":-32000,"message":"boom"}}`))
+ if err == nil || !strings.Contains(err.Error(), "response") {
+ t.Fatalf("expected an explicit error for an error frame too,
got: %v", err)
+ }
+
+ // Requests and notifications must keep decoding.
+ if _, err :=
decodeMCPMessage([]byte(`{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{}}`));
err != nil {
+ t.Fatalf("request frame should still decode: %v", err)
+ }
+ if _, err :=
decodeMCPMessage([]byte(`{"jsonrpc":"2.0","method":"notifications/initialized"}`));
err != nil {
+ t.Fatalf("notification frame should still decode: %v", err)
+ }
+}