diff --git a/internal/jsonrpc2/decimal_id_test.go b/internal/jsonrpc2/decimal_id_test.go new file mode 100644 index 000000000..dfad87755 --- /dev/null +++ b/internal/jsonrpc2/decimal_id_test.go @@ -0,0 +1,115 @@ +// Copyright 2025 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by the license +// that can be found in the LICENSE file. + +package jsonrpc2 + +import ( + "encoding/json" + "math/big" + "strconv" + "strings" + "testing" +) + +func TestDecodeDecimalIntegerIDs(t *testing.T) { + for _, tc := range []struct { + wire string + want int64 + }{ + {"9007199254740993", 9007199254740993}, + {"9007199254740993.0", 9007199254740993}, + {"90071992547409930e-1", 9007199254740993}, + {"-9007199254740993.0", -9007199254740993}, + {"9223372036854775807.0", 9223372036854775807}, + {"-9223372036854775808.0", -9223372036854775808}, + {"9.007199254740993e15", 9007199254740993}, + {"9007199254740993000E-3", 9007199254740993}, + {"0.00042e5", 42}, + {"-0e999999999999999999999", 0}, + {"42.0", 42}, + {"420e-1", 42}, + } { + t.Run(tc.wire, func(t *testing.T) { + for _, suffix := range []string{`,"method":"m"}`, `,"result":{}}`} { + wire := []byte(`{"jsonrpc":"2.0","id":` + tc.wire + suffix) + msg, err := DecodeMessage(wire) + if err != nil { + t.Fatal(err) + } + var id ID + switch m := msg.(type) { + case *Request: + id = m.ID + case *Response: + id = m.ID + } + if id != Int64ID(tc.want) { + t.Errorf("wire %s: got %v, want %d", wire, id.Raw(), tc.want) + } + } + id, err := MakeID(json.Number(tc.wire)) + if err != nil || id != Int64ID(tc.want) { + t.Errorf("MakeID: got %v, err %v, want %d", id.Raw(), err, tc.want) + } + }) + } +} + +func TestNumericIDLegacyFallback(t *testing.T) { + for _, value := range []string{"1.5", "-1.5", "9223372036854775808", "-9223372036854775809", "1e100", "1e-100", "1e99999999999999999999", "invalid"} { + f, oldErr := strconv.ParseFloat(value, 64) + got, err := MakeID(json.Number(value)) + if oldErr != nil { + if err == nil { + t.Errorf("MakeID(%q) unexpectedly succeeded", value) + } + } else if err != nil || got != Int64ID(int64(f)) { + t.Errorf("MakeID(%q) = %v, %v; want legacy %v", value, got.Raw(), err, int64(f)) + } + } + for _, f := range []float64{1.5, -1.5, 9007199254740992} { + got, err := MakeID(f) + if err != nil || got != Int64ID(int64(f)) { + t.Errorf("MakeID(%v) = %v, %v", f, got.Raw(), err) + } + } +} + +func FuzzExactIntegerID(f *testing.F) { + for _, s := range []string{"9007199254740993.0", "90071992547409930e-1", "9e18", "-9223372036854775808.0", "1.5", "0", "0e99999999999999999999", "1e-9999999999999999999"} { + f.Add(s) + } + f.Fuzz(func(t *testing.T, s string) { + if len(s) > 256 || !json.Valid([]byte(s)) || strings.TrimSpace(s) != s { + return + } + // Bound the oracle's exponent; the production parser has no such input cap. + if pos := strings.IndexAny(s, "eE"); pos >= 0 { + e, err := strconv.Atoi(s[pos+1:]) + if err != nil || e < -300 || e > 300 { + return + } + } + want, ok := new(big.Rat).SetString(s) + if !ok { + return + } + got, exact := exactIntegerID(s) + valid := want.IsInt() && want.Num().IsInt64() + if exact != valid || (exact && got != want.Num().Int64()) { + t.Fatalf("exactIntegerID(%q) = %d, %v; want %s, %v", s, got, exact, want, valid) + } + }) +} + +func BenchmarkMakeNumericID(b *testing.B) { + for _, s := range []string{"9007199254740993", "9007199254740993.0", "90071992547409930e-1", "1e99999999999999999999"} { + b.Run(s, func(b *testing.B) { + b.ReportAllocs() + for b.Loop() { + _, _ = MakeID(json.Number(s)) + } + }) + } +} diff --git a/internal/jsonrpc2/messages.go b/internal/jsonrpc2/messages.go index dc445445c..a2d3483d9 100644 --- a/internal/jsonrpc2/messages.go +++ b/internal/jsonrpc2/messages.go @@ -9,6 +9,8 @@ import ( "encoding/json" "errors" "fmt" + "strconv" + "strings" internaljson "github.com/modelcontextprotocol/go-sdk/internal/json" ) @@ -21,8 +23,9 @@ type ID struct { // 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. +// Decoding with json.Number keeps integer IDs in the int64 range exact, +// including decimal and exponent notation. Other numbers retain the legacy +// float64 conversion behavior. // // Returns an error if the value type was not a valid Request ID type. // @@ -39,6 +42,9 @@ func MakeID(v any) (ID, error) { if i, err := v.Int64(); err == nil { return Int64ID(i), nil } + if i, ok := exactIntegerID(string(v)); ok { + return Int64ID(i), nil + } // Fallback to maintain backward compatibility if f, err := v.Float64(); err == nil { return Int64ID(int64(f)), nil @@ -50,6 +56,63 @@ func MakeID(v any) (ID, error) { return ID{}, fmt.Errorf("%w: invalid ID type %T", ErrParse, v) } +// exactIntegerID normalizes decimal digits without converting through float64 +// or allocating storage proportional to the exponent. +func exactIntegerID(s string) (int64, bool) { + if !json.Valid([]byte(s)) || len(s) == 0 { + return 0, false + } + negative := s[0] == '-' + if negative { + s = s[1:] + } + mantissa, exponent := s, "0" + if i := strings.IndexAny(s, "eE"); i >= 0 { + mantissa, exponent = s[:i], s[i+1:] + } + fractional := 0 + if i := strings.IndexByte(mantissa, '.'); i >= 0 { + fractional = len(mantissa) - i - 1 + mantissa = mantissa[:i] + mantissa[i+1:] + } + for _, c := range mantissa { + if c < '0' || c > '9' { + return 0, false + } + } + if len(mantissa) == 0 { + return 0, false + } + digits := strings.TrimLeft(mantissa, "0") + if digits == "" { + return 0, true + } + exp, err := strconv.Atoi(exponent) + if err != nil || exp > len(s)+19 || exp < -len(s) { + return 0, false + } + // Remove trailing zeroes before applying the exponent, so a negative scale + // can only describe a genuinely fractional number. + significant := strings.TrimRight(digits, "0") + scale := exp - fractional + len(digits) - len(significant) + if scale < 0 || scale > 19 || len(significant) > 19-scale { + return 0, false + } + var buf [20]byte + n := 0 + if negative { + buf[n] = '-' + n++ + } + n += copy(buf[n:], significant) + for range scale { + buf[n] = '0' + n++ + } + i, err := strconv.ParseInt(string(buf[:n]), 10, 64) + return i, err == nil +} + // Message is the interface to all jsonrpc2 message types. // They share no common functionality, but are a closed set of concrete types // that are allowed to implement this interface. The message types are *Request diff --git a/jsonrpc/jsonrpc.go b/jsonrpc/jsonrpc.go index bbc2efe4d..4b401c62a 100644 --- a/jsonrpc/jsonrpc.go +++ b/jsonrpc/jsonrpc.go @@ -23,8 +23,9 @@ type ( // 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. +// Decoding with json.Number keeps integer IDs in the int64 range exact, +// including decimal and exponent notation. Other numbers retain the legacy +// float64 conversion behavior. // // Returns an error if the value type was not a valid Request ID type. func MakeID(v any) (ID, error) { diff --git a/mcp/decimal_cancel_test.go b/mcp/decimal_cancel_test.go new file mode 100644 index 000000000..43f05a0ad --- /dev/null +++ b/mcp/decimal_cancel_test.go @@ -0,0 +1,70 @@ +// Copyright 2025 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by the license +// that can be found in the LICENSE file. + +package mcp + +import ( + "context" + "encoding/json" + "io" + "log/slog" + "net" + "testing" + "time" + + "github.com/modelcontextprotocol/go-sdk/internal/jsonrpc2" +) + +func TestCancelDecimalIntegerID(t *testing.T) { + for _, id := range []string{"9007199254740993.0", "90071992547409930e-1"} { + t.Run(id, func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + a, b := net.Pipe() + transport, peer := newIOConn(a), newIOConn(b) + started := make(chan struct{}) + canceled := make(chan struct{}) + preempt := &canceller{logger: slog.New(slog.NewTextHandler(io.Discard, nil))} + conn := jsonrpc2.NewConnection(ctx, jsonrpc2.ConnectionConfig{ + Reader: transport, Writer: transport, Closer: transport, Preempter: preempt, + Bind: func(c *jsonrpc2.Connection) jsonrpc2.Handler { + preempt.conn = c + return jsonrpc2.HandlerFunc(func(callCtx context.Context, req *jsonrpc2.Request) (any, error) { + if req.Method != "wait" { + return nil, jsonrpc2.ErrNotHandled + } + close(started) + select { + case <-callCtx.Done(): + close(canceled) + return nil, callCtx.Err() + case <-ctx.Done(): + return nil, ctx.Err() + } + }) + }, + }) + t.Cleanup(func() { cancel(); peer.Close(); conn.Close() }) + if err := peer.Write(ctx, &jsonrpc2.Request{ID: jsonrpc2.Int64ID(9007199254740993), Method: "wait"}); err != nil { + t.Fatal(err) + } + select { + case <-started: + case <-ctx.Done(): + t.Fatal(ctx.Err()) + } + if err := peer.Write(ctx, &jsonrpc2.Request{Method: notificationCancelled, Params: json.RawMessage(`{"requestId":` + id + `}`)}); err != nil { + t.Fatal(err) + } + select { + case <-canceled: + case <-ctx.Done(): + t.Fatal("decimal requestId did not cancel the matching integer request") + } + if _, err := peer.Read(ctx); err != nil { + t.Fatal(err) + } + }) + } +}