From 2d2af9e631232849ab05eb1dda3fac27db6920be Mon Sep 17 00:00:00 2001 From: guglielmoc Date: Wed, 14 Oct 2026 16:27:37 +0000 Subject: [PATCH 1/6] fix: support exact JSON number decoding for large request IDs above 2^53 --- internal/jsonrpc2/messages.go | 18 +++++-- internal/jsonrpc2/wire_test.go | 49 +++++++++++++++++++ jsonrpc/jsonrpc.go | 6 ++- mcp/mcp_test.go | 87 ++++++++++++++++++++++++++++++++++ mcp/transport.go | 3 +- 5 files changed, 157 insertions(+), 6 deletions(-) diff --git a/internal/jsonrpc2/messages.go b/internal/jsonrpc2/messages.go index 8b9677061..464dcb06c 100644 --- a/internal/jsonrpc2/messages.go +++ b/internal/jsonrpc2/messages.go @@ -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. // @@ -33,6 +35,14 @@ func MakeID(v any) (ID, error) { return ID{}, nil case float64: return Int64ID(int64(v)), nil + case json.Number: + // MCP requires integer IDs, and encoders write them as plain integer + // literals; a fraction or exponent only comes from a float-typed ID. + i, err := v.Int64() + if err != nil { + return ID{}, fmt.Errorf("%w: numeric ID is not an int64 integer", ErrParse) + } + return Int64ID(i), nil case string: return StringID(v), nil } @@ -185,7 +195,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 { diff --git a/internal/jsonrpc2/wire_test.go b/internal/jsonrpc2/wire_test.go index ef4714405..fa8d89093 100644 --- a/internal/jsonrpc2/wire_test.go +++ b/internal/jsonrpc2/wire_test.go @@ -148,6 +148,55 @@ 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)) + } + } + + for _, wire := range []string{`1.5`, `1e3`, `1000.0`, `9223372036854775808`, `-9223372036854775809`} { + encoded := `{"jsonrpc":"2.0","id":` + wire + `,"method":"m"}` + if _, err := jsonrpc2.DecodeMessage([]byte(encoded)); !errors.Is(err, jsonrpc2.ErrParse) { + t.Errorf("DecodeMessage(%s) error = %v, want ErrParse", encoded, err) + } + } +} + // TestWireErrorIs checks the errors.Is behavior of WireError, including that // comparing against a typed-nil *WireError does not panic. func TestWireErrorIs(t *testing.T) { diff --git a/jsonrpc/jsonrpc.go b/jsonrpc/jsonrpc.go index a9ea78fa8..bbc2efe4d 100644 --- a/jsonrpc/jsonrpc.go +++ b/jsonrpc/jsonrpc.go @@ -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) { diff --git a/mcp/mcp_test.go b/mcp/mcp_test.go index c53e6d571..72105cd2d 100644 --- a/mcp/mcp_test.go +++ b/mcp/mcp_test.go @@ -801,6 +801,93 @@ func TestCancellationReason(t *testing.T) { }) } +// TestCancellationLargeIDs verifies that request IDs above 2^53, which a +// float64 cannot hold, stay distinct: of two in-flight calls with adjacent +// IDs, a cancelled notification cancels only the one it names, and the other +// is answered with its exact ID. +func TestCancellationLargeIDs(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + const ( + idA = int64(1 << 53) + idB = int64(1<<53 + 1) // rounds to idA through a float64 + ) + type outcome struct { + n int + cancelled bool + } + started := make(chan struct{}, 2) + outcomes := make(chan outcome, 2) + slowTool := func(ctx context.Context, req *CallToolRequest, args struct{ N int }) (*CallToolResult, any, error) { + started <- struct{}{} + select { + case <-ctx.Done(): + outcomes <- outcome{args.N, true} + case <-time.After(5 * time.Second): + outcomes <- outcome{args.N, false} + } + return &CallToolResult{}, nil, nil + } + ctx := context.Background() + ct, st := NewInMemoryTransports() + s := NewServer(testImpl, nil) + AddTool(s, &Tool{Name: "slow"}, slowTool) + ss, err := s.Connect(ctx, st, nil) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = ss.Close() }) + // Write to the connection directly, to choose the request IDs. + conn, err := ct.Connect(ctx) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = conn.Close() }) + write := func(msg jsonrpc.Message, err error) { + t.Helper() + if err != nil { + t.Fatal(err) + } + if err := conn.Write(ctx, msg); err != nil { + t.Fatal(err) + } + } + + write(jsonrpc2.NewCall(jsonrpc2.Int64ID(1), methodInitialize, &InitializeParams{ + ProtocolVersion: protocolVersion20251125, + ClientInfo: testImpl, + Capabilities: &ClientCapabilities{}, + })) + if _, err := conn.Read(ctx); err != nil { + t.Fatal(err) + } + write(jsonrpc2.NewNotification(notificationInitialized, &InitializedParams{})) + write(jsonrpc2.NewCall(jsonrpc2.Int64ID(idA), methodCallTool, &CallToolParams{Name: "slow", Arguments: map[string]any{"N": 1}})) + write(jsonrpc2.NewCall(jsonrpc2.Int64ID(idB), methodCallTool, &CallToolParams{Name: "slow", Arguments: map[string]any{"N": 2}})) + <-started + <-started + write(jsonrpc2.NewNotification(notificationCancelled, &CancelledParams{RequestID: idB})) + + for range 2 { + o := <-outcomes + if want := o.n == 2; o.cancelled != want { + t.Errorf("call %d: cancelled = %t, want %t", o.n, o.cancelled, want) + } + } + for { + msg, err := conn.Read(ctx) + if err != nil { + t.Fatal(err) + } + if resp, ok := msg.(*jsonrpc.Response); ok && resp.ID == jsonrpc2.Int64ID(idA) { + if resp.Error != nil { + t.Errorf("call 1 response error = %v, want a result", resp.Error) + } + break + } + } + }) +} + func TestMiddleware(t *testing.T) { ctx := context.Background() ct, st := NewInMemoryTransports() diff --git a/mcp/transport.go b/mcp/transport.go index d2423d9dd..e7414d699 100644 --- a/mcp/transport.go +++ b/mcp/transport.go @@ -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, ¶ms); err != nil { + // UseNumber keeps a numeric requestId exact, as in jsonrpc2.DecodeMessage. + if err := internaljson.UnmarshalUseNumber(req.Params, ¶ms); err != nil { return nil, err } id, err := jsonrpc2.MakeID(params.RequestID) From ae4a2c321fc58660e249425d03d4f13cfb4621b1 Mon Sep 17 00:00:00 2001 From: guglielmoc Date: Thu, 15 Oct 2026 14:21:42 +0000 Subject: [PATCH 2/6] fix: restore float64 fallback parsing for json.Number IDs for backward compatibility --- internal/jsonrpc2/messages.go | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/internal/jsonrpc2/messages.go b/internal/jsonrpc2/messages.go index 464dcb06c..b07f997d7 100644 --- a/internal/jsonrpc2/messages.go +++ b/internal/jsonrpc2/messages.go @@ -36,13 +36,14 @@ func MakeID(v any) (ID, error) { case float64: return Int64ID(int64(v)), nil case json.Number: - // MCP requires integer IDs, and encoders write them as plain integer - // literals; a fraction or exponent only comes from a float-typed ID. - i, err := v.Int64() - if err != nil { - return ID{}, fmt.Errorf("%w: numeric ID is not an int64 integer", ErrParse) + if i, err := v.Int64(); err == nil { + return Int64ID(i), nil } - return Int64ID(i), nil + // Keep this fallback conversion 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 } From e5bb06bb58b0ce4ec1a9bc2c9b155ba1c8550043 Mon Sep 17 00:00:00 2001 From: guglielmoc Date: Thu, 15 Oct 2026 15:02:02 +0000 Subject: [PATCH 3/6] fix: handle integer range overflow correctly durin --- internal/jsonrpc2/messages.go | 11 +++-- internal/jsonrpc2/wire_test.go | 2 +- mcp/mcp_test.go | 87 ---------------------------------- 3 files changed, 8 insertions(+), 92 deletions(-) diff --git a/internal/jsonrpc2/messages.go b/internal/jsonrpc2/messages.go index b07f997d7..396f49153 100644 --- a/internal/jsonrpc2/messages.go +++ b/internal/jsonrpc2/messages.go @@ -9,6 +9,8 @@ import ( "encoding/json" "errors" "fmt" + "math" + "strconv" internaljson "github.com/modelcontextprotocol/go-sdk/internal/json" ) @@ -38,10 +40,11 @@ func MakeID(v any) (ID, error) { case json.Number: if i, err := v.Int64(); err == nil { return Int64ID(i), nil - } - // Keep this fallback conversion to maintain backward compatibility - if f, err := v.Float64(); err == nil { - return Int64ID(int64(f)), nil + } else if !errors.Is(err, strconv.ErrRange) { + // Fallback to float64 to maintain backward compatibility for large integers + if f, err := v.Float64(); err == nil && f >= math.MinInt64 && f < math.MaxInt64 { + return Int64ID(int64(f)), nil + } } return ID{}, fmt.Errorf("%w: invalid numeric ID %q", ErrParse, v) case string: diff --git a/internal/jsonrpc2/wire_test.go b/internal/jsonrpc2/wire_test.go index fa8d89093..ab6c0c1a1 100644 --- a/internal/jsonrpc2/wire_test.go +++ b/internal/jsonrpc2/wire_test.go @@ -189,7 +189,7 @@ func TestDecodeIntegerIDs(t *testing.T) { } } - for _, wire := range []string{`1.5`, `1e3`, `1000.0`, `9223372036854775808`, `-9223372036854775809`} { + for _, wire := range []string{`9223372036854775808`, `-9223372036854775809`, `1e20`} { encoded := `{"jsonrpc":"2.0","id":` + wire + `,"method":"m"}` if _, err := jsonrpc2.DecodeMessage([]byte(encoded)); !errors.Is(err, jsonrpc2.ErrParse) { t.Errorf("DecodeMessage(%s) error = %v, want ErrParse", encoded, err) diff --git a/mcp/mcp_test.go b/mcp/mcp_test.go index 72105cd2d..c53e6d571 100644 --- a/mcp/mcp_test.go +++ b/mcp/mcp_test.go @@ -801,93 +801,6 @@ func TestCancellationReason(t *testing.T) { }) } -// TestCancellationLargeIDs verifies that request IDs above 2^53, which a -// float64 cannot hold, stay distinct: of two in-flight calls with adjacent -// IDs, a cancelled notification cancels only the one it names, and the other -// is answered with its exact ID. -func TestCancellationLargeIDs(t *testing.T) { - synctest.Test(t, func(t *testing.T) { - const ( - idA = int64(1 << 53) - idB = int64(1<<53 + 1) // rounds to idA through a float64 - ) - type outcome struct { - n int - cancelled bool - } - started := make(chan struct{}, 2) - outcomes := make(chan outcome, 2) - slowTool := func(ctx context.Context, req *CallToolRequest, args struct{ N int }) (*CallToolResult, any, error) { - started <- struct{}{} - select { - case <-ctx.Done(): - outcomes <- outcome{args.N, true} - case <-time.After(5 * time.Second): - outcomes <- outcome{args.N, false} - } - return &CallToolResult{}, nil, nil - } - ctx := context.Background() - ct, st := NewInMemoryTransports() - s := NewServer(testImpl, nil) - AddTool(s, &Tool{Name: "slow"}, slowTool) - ss, err := s.Connect(ctx, st, nil) - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { _ = ss.Close() }) - // Write to the connection directly, to choose the request IDs. - conn, err := ct.Connect(ctx) - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { _ = conn.Close() }) - write := func(msg jsonrpc.Message, err error) { - t.Helper() - if err != nil { - t.Fatal(err) - } - if err := conn.Write(ctx, msg); err != nil { - t.Fatal(err) - } - } - - write(jsonrpc2.NewCall(jsonrpc2.Int64ID(1), methodInitialize, &InitializeParams{ - ProtocolVersion: protocolVersion20251125, - ClientInfo: testImpl, - Capabilities: &ClientCapabilities{}, - })) - if _, err := conn.Read(ctx); err != nil { - t.Fatal(err) - } - write(jsonrpc2.NewNotification(notificationInitialized, &InitializedParams{})) - write(jsonrpc2.NewCall(jsonrpc2.Int64ID(idA), methodCallTool, &CallToolParams{Name: "slow", Arguments: map[string]any{"N": 1}})) - write(jsonrpc2.NewCall(jsonrpc2.Int64ID(idB), methodCallTool, &CallToolParams{Name: "slow", Arguments: map[string]any{"N": 2}})) - <-started - <-started - write(jsonrpc2.NewNotification(notificationCancelled, &CancelledParams{RequestID: idB})) - - for range 2 { - o := <-outcomes - if want := o.n == 2; o.cancelled != want { - t.Errorf("call %d: cancelled = %t, want %t", o.n, o.cancelled, want) - } - } - for { - msg, err := conn.Read(ctx) - if err != nil { - t.Fatal(err) - } - if resp, ok := msg.(*jsonrpc.Response); ok && resp.ID == jsonrpc2.Int64ID(idA) { - if resp.Error != nil { - t.Errorf("call 1 response error = %v, want a result", resp.Error) - } - break - } - } - }) -} - func TestMiddleware(t *testing.T) { ctx := context.Background() ct, st := NewInMemoryTransports() From 6b311b5ad08a3c3186634dd6d004d9fded580110 Mon Sep 17 00:00:00 2001 From: guglielmoc Date: Thu, 15 Oct 2026 15:12:11 +0000 Subject: [PATCH 4/6] refactor: shorten comment for jsonrpc2 integer ID fallback logic --- internal/jsonrpc2/messages.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/jsonrpc2/messages.go b/internal/jsonrpc2/messages.go index 396f49153..d4c61bf17 100644 --- a/internal/jsonrpc2/messages.go +++ b/internal/jsonrpc2/messages.go @@ -41,7 +41,7 @@ func MakeID(v any) (ID, error) { if i, err := v.Int64(); err == nil { return Int64ID(i), nil } else if !errors.Is(err, strconv.ErrRange) { - // Fallback to float64 to maintain backward compatibility for large integers + // Fallback to float64 to maintain backward compatibility if f, err := v.Float64(); err == nil && f >= math.MinInt64 && f < math.MaxInt64 { return Int64ID(int64(f)), nil } From 0c8664f3bc0bf7f4a8ab40694112b1dc03e6eaa5 Mon Sep 17 00:00:00 2001 From: guglielmoc Date: Thu, 15 Oct 2026 15:21:17 +0000 Subject: [PATCH 5/6] refactor: simplify JSON-RPC numeric ID parsing and remove unused math and strconv imports --- internal/jsonrpc2/messages.go | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/internal/jsonrpc2/messages.go b/internal/jsonrpc2/messages.go index d4c61bf17..dc445445c 100644 --- a/internal/jsonrpc2/messages.go +++ b/internal/jsonrpc2/messages.go @@ -9,8 +9,6 @@ import ( "encoding/json" "errors" "fmt" - "math" - "strconv" internaljson "github.com/modelcontextprotocol/go-sdk/internal/json" ) @@ -40,11 +38,10 @@ func MakeID(v any) (ID, error) { case json.Number: if i, err := v.Int64(); err == nil { return Int64ID(i), nil - } else if !errors.Is(err, strconv.ErrRange) { - // Fallback to float64 to maintain backward compatibility - if f, err := v.Float64(); err == nil && f >= math.MinInt64 && f < math.MaxInt64 { - return Int64ID(int64(f)), 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: From f9a7e19dda81e96635a3f362a3ac34ec7702f4b3 Mon Sep 17 00:00:00 2001 From: guglielmoc Date: Thu, 15 Oct 2026 15:23:20 +0000 Subject: [PATCH 6/6] test: remove out-of-range integer and float tests for jsonrpc2 message IDs in wire_test.go --- internal/jsonrpc2/wire_test.go | 7 ------- 1 file changed, 7 deletions(-) diff --git a/internal/jsonrpc2/wire_test.go b/internal/jsonrpc2/wire_test.go index ab6c0c1a1..9fdd49959 100644 --- a/internal/jsonrpc2/wire_test.go +++ b/internal/jsonrpc2/wire_test.go @@ -188,13 +188,6 @@ func TestDecodeIntegerIDs(t *testing.T) { checkJSON(t, reencoded, []byte(encoded)) } } - - for _, wire := range []string{`9223372036854775808`, `-9223372036854775809`, `1e20`} { - encoded := `{"jsonrpc":"2.0","id":` + wire + `,"method":"m"}` - if _, err := jsonrpc2.DecodeMessage([]byte(encoded)); !errors.Is(err, jsonrpc2.ErrParse) { - t.Errorf("DecodeMessage(%s) error = %v, want ErrParse", encoded, err) - } - } } // TestWireErrorIs checks the errors.Is behavior of WireError, including that