diff --git a/internal/jsonrpc2/messages.go b/internal/jsonrpc2/messages.go index 8b967706..09fbb241 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 cf7e2b86..7671fc75 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}`)