Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 115 additions & 0 deletions internal/jsonrpc2/decimal_id_test.go
Original file line number Diff line number Diff line change
@@ -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))
}
})
}
}
67 changes: 65 additions & 2 deletions internal/jsonrpc2/messages.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import (
"encoding/json"
"errors"
"fmt"
"strconv"
"strings"

internaljson "github.com/modelcontextprotocol/go-sdk/internal/json"
)
Expand All @@ -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.
//
Expand All @@ -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
Expand All @@ -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
Expand Down
5 changes: 3 additions & 2 deletions jsonrpc/jsonrpc.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
70 changes: 70 additions & 0 deletions mcp/decimal_cancel_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}
Loading