Skip to content
19 changes: 16 additions & 3 deletions internal/jsonrpc2/messages.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,10 @@ type ID struct {
value any
}

// MakeID coerces the given Go value to an ID. The value should be the
// default JSON marshaling of a Request identifier: nil, float64, or string.
// MakeID coerces the given Go value to an ID. The value should be the JSON
// decoding of a Request identifier: nil, float64, json.Number, or string.
// Decoding with json.Number keeps integer IDs above 2^53 exact; a json.Number
// must be an integer literal in the int64 range.
//
// Returns an error if the value type was not a valid Request ID type.
//
Expand All @@ -33,6 +35,15 @@ func MakeID(v any) (ID, error) {
return ID{}, nil
case float64:
return Int64ID(int64(v)), nil
case json.Number:
if i, err := v.Int64(); err == nil {
return Int64ID(i), nil
}
// Fallback to maintain backward compatibility
if f, err := v.Float64(); err == nil {
return Int64ID(int64(f)), nil
}
return ID{}, fmt.Errorf("%w: invalid numeric ID %q", ErrParse, v)
case string:
return StringID(v), nil
}
Expand Down Expand Up @@ -185,7 +196,9 @@ type wireDecode struct {

func DecodeMessage(data []byte) (Message, error) {
msg := wireDecode{}
if err := internaljson.Unmarshal(data, &msg); err != nil {
// UseNumber keeps a numeric ID exact (see MakeID). The other fields are
// raw or typed, so it affects nothing else.
if err := internaljson.UnmarshalUseNumber(data, &msg); err != nil {
return nil, fmt.Errorf("unmarshaling jsonrpc message: %w", err)
}
if msg.VersionTag != wireVersion {
Expand Down
42 changes: 42 additions & 0 deletions internal/jsonrpc2/wire_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,48 @@ func TestDecodeIDOnlyMessageIsResponse(t *testing.T) {
}
}

// TestDecodeIntegerIDs checks that numeric IDs decode exactly, including
// integers above 2^53 that a float64 cannot hold, and that a numeric ID that
// is not an int64 integer literal is rejected.
func TestDecodeIntegerIDs(t *testing.T) {
for _, tc := range []struct {
wire string
want jsonrpc2.ID
}{
{`9007199254740992`, jsonrpc2.Int64ID(9007199254740992)},
{`9007199254740993`, jsonrpc2.Int64ID(9007199254740993)},
{`9223372036854775807`, jsonrpc2.Int64ID(9223372036854775807)},
{`-9223372036854775808`, jsonrpc2.Int64ID(-9223372036854775808)},
{`"9007199254740993"`, jsonrpc2.StringID("9007199254740993")},
} {
for _, encoded := range []string{
`{"jsonrpc":"2.0","id":` + tc.wire + `,"method":"m"}`,
`{"jsonrpc":"2.0","id":` + tc.wire + `,"result":{}}`,
} {
msg, err := jsonrpc2.DecodeMessage([]byte(encoded))
if err != nil {
t.Fatalf("DecodeMessage(%s): %v", encoded, err)
}
var got jsonrpc2.ID
switch msg := msg.(type) {
case *jsonrpc2.Request:
got = msg.ID
case *jsonrpc2.Response:
got = msg.ID
}
if got != tc.want {
t.Errorf("DecodeMessage(%s) ID = %v, want %v", encoded, got.Raw(), tc.want.Raw())
}
// A response echoes the request ID, so it must encode back unchanged.
reencoded, err := jsonrpc2.EncodeMessage(msg)
if err != nil {
t.Fatal(err)
}
checkJSON(t, reencoded, []byte(encoded))
}
}
}

// TestWireErrorIs checks the errors.Is behavior of WireError, including that
// comparing against a typed-nil *WireError does not panic.
func TestWireErrorIs(t *testing.T) {
Expand Down
6 changes: 4 additions & 2 deletions jsonrpc/jsonrpc.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,10 @@ type (
Error = jsonrpc2.WireError
)

// MakeID coerces the given Go value to an ID. The value should be the
// default JSON marshaling of a Request identifier: nil, float64, or string.
// MakeID coerces the given Go value to an ID. The value should be the JSON
// decoding of a Request identifier: nil, float64, json.Number, or string.
// Decoding with json.Number keeps integer IDs above 2^53 exact; a json.Number
// must be an integer literal in the int64 range.
//
// Returns an error if the value type was not a valid Request ID type.
func MakeID(v any) (ID, error) {
Expand Down
3 changes: 2 additions & 1 deletion mcp/transport.go
Original file line number Diff line number Diff line change
Expand Up @@ -260,7 +260,8 @@ type canceller struct {
func (c *canceller) Preempt(ctx context.Context, req *jsonrpc.Request) (result any, err error) {
if req.Method == notificationCancelled {
var params CancelledParams
if err := internaljson.Unmarshal(req.Params, &params); err != nil {
// UseNumber keeps a numeric requestId exact, as in jsonrpc2.DecodeMessage.
if err := internaljson.UnmarshalUseNumber(req.Params, &params); err != nil {
return nil, err
}
id, err := jsonrpc2.MakeID(params.RequestID)
Expand Down
Loading