From 395febb321ff0d413c02c401bbb2a8966bb38d32 Mon Sep 17 00:00:00 2001 From: meraklbz Date: Sun, 16 Aug 2026 22:08:00 +0800 Subject: [PATCH] internal/jsonrpc2: reject fractional and out-of-range request IDs MakeID currently truncates any float64 request ID with int64(), so a request with id 1.9 is answered with id 1. JSON-RPC 2.0 specifies that request IDs must be integers, strings, or null; silently rewriting a non-integer ID breaks response correlation for the client and can cause responses for distinct requests to be mixed up. Validate that float64 IDs are whole numbers within the int64 range and return a parse error otherwise, matching the behavior of the other official SDKs. --- internal/jsonrpc2/messages.go | 6 +++++ internal/jsonrpc2/wire_test.go | 48 ++++++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+) diff --git a/internal/jsonrpc2/messages.go b/internal/jsonrpc2/messages.go index 8b9677061..09fbb2418 100644 --- a/internal/jsonrpc2/messages.go +++ b/internal/jsonrpc2/messages.go @@ -9,6 +9,7 @@ import ( "encoding/json" "errors" "fmt" + "math" internaljson "github.com/modelcontextprotocol/go-sdk/internal/json" ) @@ -32,6 +33,11 @@ func MakeID(v any) (ID, error) { case nil: return ID{}, nil case float64: + // JSON-RPC request IDs are integers; reject fractional or out-of-range + // values instead of silently truncating them. + if v != math.Trunc(v) || v >= 9.223372036854775808e18 || v < -9.223372036854775808e18 { + return ID{}, fmt.Errorf("%w: request id must be an integer, got %v", ErrParse, v) + } return Int64ID(int64(v)), nil case string: return StringID(v), nil diff --git a/internal/jsonrpc2/wire_test.go b/internal/jsonrpc2/wire_test.go index cf7e2b866..7671fc75e 100644 --- a/internal/jsonrpc2/wire_test.go +++ b/internal/jsonrpc2/wire_test.go @@ -135,6 +135,54 @@ func TestDecodeResponseUnchanged(t *testing.T) { } } +func TestMakeIDRejectsNonInteger(t *testing.T) { + for _, test := range []struct { + name string + id any + }{ + {name: "fractional", id: 1.9}, + {name: "half", id: 2.5}, + {name: "above int64 range", id: 9.3e18}, + {name: "below int64 range", id: -9.3e18}, + } { + t.Run(test.name, func(t *testing.T) { + if _, err := jsonrpc2.MakeID(test.id); err == nil { + t.Errorf("MakeID(%v) = nil error, want ErrParse", test.id) + } + }) + } +} + +func TestMakeIDAcceptsInteger(t *testing.T) { + for _, test := range []struct { + name string + id any + want int64 + }{ + {name: "zero", id: float64(0), want: 0}, + {name: "positive", id: float64(42), want: 42}, + {name: "negative", id: float64(-7), want: -7}, + {name: "max exact float64 integer", id: float64(1 << 53), want: 1 << 53}, + } { + t.Run(test.name, func(t *testing.T) { + id, err := jsonrpc2.MakeID(test.id) + if err != nil { + t.Fatalf("MakeID(%v) = error: %v", test.id, err) + } + if got := id.Raw(); got != test.want { + t.Errorf("MakeID(%v).Raw() = %v, want %v", test.id, got, test.want) + } + }) + } +} + +func TestDecodeMessageRejectsFractionalID(t *testing.T) { + encoded := []byte(`{"jsonrpc":"2.0","id":1.9,"method":"ping"}`) + if _, err := jsonrpc2.DecodeMessage(encoded); err == nil { + t.Fatal("DecodeMessage with fractional id = nil error, want error") + } +} + // Messages with an id but no "method" key are responses, not malformed requests. func TestDecodeIDOnlyMessageIsResponse(t *testing.T) { encoded := []byte(`{"jsonrpc":"2.0","id":5}`)