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
16 changes: 16 additions & 0 deletions internal/json/json.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,13 +37,29 @@ func (d *Decoder) Decode(v any) error {
return d.dec.Decode(v)
}

// UseNumber causes the decoder to unmarshal a number into a [json.Number]
// rather than a float64, preserving the number's original literal text.
func (d *Decoder) UseNumber() {
d.dec.UseNumber()
}

func Unmarshal(data []byte, v any) error {
if err := checkMaxDepth(data, defaultMaxDepth); err != nil {
return err
}
return NewDecoder(bytes.NewReader(data)).Decode(v)
}

// UnmarshalUseNumber is [Unmarshal] with [Decoder.UseNumber] set.
func UnmarshalUseNumber(data []byte, v any) error {
if err := checkMaxDepth(data, defaultMaxDepth); err != nil {
return err
}
dec := NewDecoder(bytes.NewReader(data))
dec.UseNumber()
return dec.Decode(v)
}

// checkMaxDepth scans data once and reports [errMaxDepthExceeded] if the
// nesting of JSON objects and arrays exceeds maxDepth. It is a lightweight pass which
// tracks '{' and '[' against '}' and ']' while skipping over the contents of strings.
Expand Down
43 changes: 43 additions & 0 deletions mcp/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1932,3 +1932,46 @@ func TestServerSupportedProtocolVersions_NewProtocol(t *testing.T) {
t.Errorf("UnsupportedProtocolVersionData.Supported mismatch (-want +got):\n%s", diff)
}
}

// TestToolArgumentIntegerPrecision exercises the typed AddTool path end to end
// with an integer outside the IEEE-754 safe range (issue #1201).
func TestToolArgumentIntegerPrecision(t *testing.T) {
ctx := context.Background()

type input struct {
ID int64 `json:"id"`
}
const want int64 = 9007199254740993 // 2^53 + 1

var got int64
server := NewServer(&Implementation{Name: "testServer", Version: "v1.0.0"}, nil)
AddTool(server, &Tool{Name: "echo_id"},
func(_ context.Context, _ *CallToolRequest, in input) (*CallToolResult, input, error) {
got = in.ID
return nil, in, nil
})

cTransport, sTransport := NewInMemoryTransports()
ss, err := server.Connect(ctx, sTransport, nil)
if err != nil {
t.Fatal(err)
}
defer ss.Close()

client := NewClient(&Implementation{Name: "testClient", Version: "v1.0.0"}, nil)
cs, err := client.Connect(ctx, cTransport, nil)
if err != nil {
t.Fatal(err)
}
defer cs.Close()

if _, err := cs.CallTool(ctx, &CallToolParams{
Name: "echo_id",
Arguments: map[string]any{"id": want},
}); err != nil {
t.Fatal(err)
}
if got != want {
t.Errorf("handler received id %d, want %d", got, want)
}
}
57 changes: 54 additions & 3 deletions mcp/tool.go
Original file line number Diff line number Diff line change
Expand Up @@ -91,18 +91,22 @@ func applySchema(data json.RawMessage, resolved *jsonschema.Resolved, forOutput
return data, nil
}

// Decode numbers as json.Number so that any re-marshalling below
// reproduces their original literal text. Decoding into any/map[string]any
// otherwise represents every JSON number as a float64, which silently
// rounds integers outside the IEEE-754 safe range (issue #1201).
var unmarshaled any
if !forOutput {
v := make(map[string]any)
if len(data) > 0 {
if err := internaljson.Unmarshal(data, &v); err != nil {
if err := internaljson.UnmarshalUseNumber(data, &v); err != nil {
return nil, fmt.Errorf("unmarshaling arguments: %w", err)
}
}
unmarshaled = v
} else {
if len(data) > 0 {
if err := internaljson.Unmarshal(data, &unmarshaled); err != nil {
if err := internaljson.UnmarshalUseNumber(data, &unmarshaled); err != nil {
return nil, fmt.Errorf("unmarshaling output: %w", err)
}
}
Expand All @@ -126,7 +130,15 @@ func applySchema(data json.RawMessage, resolved *jsonschema.Resolved, forOutput
appliedDefaults = true
}

if err := resolved.Validate(&unmarshaled); err != nil {
// Validate a float64 copy: a json.Number has reflect.Kind String, so
// jsonschema reports it as a JSON string and rejects it against
// "type": "integer". Converting keeps validation on exactly the
// representation it has always seen.
forValidation, err := jsonNumbersAsFloat(unmarshaled)
if err != nil {
return nil, err
}
if err := resolved.Validate(&forValidation); err != nil {
return nil, err
}

Expand All @@ -141,6 +153,45 @@ func applySchema(data json.RawMessage, resolved *jsonschema.Resolved, forOutput
return out, nil
}

// jsonNumbersAsFloat returns a copy of v with every [json.Number] replaced by
// the float64 it denotes, leaving all other values alone.
//
// It reports an error for a number that cannot be represented as a float64,
// matching the error that decoding it without [json.Decoder.UseNumber] would
// have produced.
func jsonNumbersAsFloat(v any) (any, error) {
switch v := v.(type) {
case map[string]any:
m := make(map[string]any, len(v))
for key, elem := range v {
c, err := jsonNumbersAsFloat(elem)
if err != nil {
return nil, err
}
m[key] = c
}
return m, nil
case []any:
s := make([]any, len(v))
for i, elem := range v {
c, err := jsonNumbersAsFloat(elem)
if err != nil {
return nil, err
}
s[i] = c
}
return s, nil
case json.Number:
f, err := v.Float64()
if err != nil {
return nil, fmt.Errorf("number %s cannot be unmarshaled into a float64", v)
}
return f, nil
default:
return v, nil
}
}

// isObjectJSON reports whether data is a JSON object (i.e., starts with '{'
// after any leading whitespace). Returns false for arrays, primitives, null,
// or empty input.
Expand Down
104 changes: 104 additions & 0 deletions mcp/tool_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -272,3 +272,107 @@ func TestValidateToolName(t *testing.T) {
})

}

// TestApplySchemaNumberPrecision verifies that applySchema preserves JSON
// integers outside the IEEE-754 safe range, which a float64 round-trip would
// silently round (issue #1201).
func TestApplySchemaNumberPrecision(t *testing.T) {
schema := &jsonschema.Schema{
Type: "object",
Properties: map[string]*jsonschema.Schema{
"id": {Type: "integer"},
"nested": {Type: "object", Properties: map[string]*jsonschema.Schema{"id": {Type: "integer"}}},
"ids": {Type: "array", Items: &jsonschema.Schema{Type: "integer"}},
"ratio": {Type: "number"},
"x": {Type: "integer", Default: json.RawMessage("3")},
},
}
resolved, err := schema.Resolve(&jsonschema.ResolveOptions{ValidateDefaults: true})
if err != nil {
t.Fatal(err)
}

// A default on the schema forces the re-marshalling path, which is where
// the rounding was observable.
for _, tt := range []struct {
name string
data string
want string
}{
{"large int64", `{"id":9007199254740993}`, `{"id":9007199254740993,"x":3}`},
{"max int64", `{"id":9223372036854775807}`, `{"id":9223372036854775807,"x":3}`},
{"min int64", `{"id":-9223372036854775808}`, `{"id":-9223372036854775808,"x":3}`},
{"nested", `{"nested":{"id":9007199254740993}}`, `{"nested":{"id":9007199254740993},"x":3}`},
{"array", `{"ids":[9007199254740993]}`, `{"ids":[9007199254740993],"x":3}`},
{"high-precision float", `{"ratio":0.1234567890123456789}`, `{"ratio":0.1234567890123456789,"x":3}`},
} {
t.Run(tt.name, func(t *testing.T) {
got, err := applySchema(json.RawMessage(tt.data), resolved, false)
if err != nil {
t.Fatalf("applySchema(%s) failed: %v", tt.data, err)
}
if string(got) != tt.want {
t.Errorf("applySchema(%s) = %s, want %s", tt.data, got, tt.want)
}
})
}
}

// TestApplySchemaOutputNumberPrecision covers the output path, where an
// object-rooted schema with a default also re-marshals the value.
func TestApplySchemaOutputNumberPrecision(t *testing.T) {
schema := &jsonschema.Schema{
Type: "object",
Properties: map[string]*jsonschema.Schema{
"id": {Type: "integer"},
"x": {Type: "integer", Default: json.RawMessage("3")},
},
}
resolved, err := schema.Resolve(&jsonschema.ResolveOptions{ValidateDefaults: true})
if err != nil {
t.Fatal(err)
}

const data = `{"id":9007199254740993}`
const want = `{"id":9007199254740993,"x":3}`
got, err := applySchema(json.RawMessage(data), resolved, true)
if err != nil {
t.Fatalf("applySchema(%s) failed: %v", data, err)
}
if string(got) != want {
t.Errorf("applySchema(%s) = %s, want %s", data, got, want)
}
}

// TestApplySchemaNumberErrors verifies that numbers which cannot be
// represented as a float64 are still rejected, and that a value of the wrong
// JSON type is still reported as such rather than as a string.
func TestApplySchemaNumberErrors(t *testing.T) {
schema := &jsonschema.Schema{
Type: "object",
Properties: map[string]*jsonschema.Schema{"id": {Type: "integer"}},
}
resolved, err := schema.Resolve(nil)
if err != nil {
t.Fatal(err)
}

for _, tt := range []struct {
name string
data string
want string
}{
{"unrepresentable", `{"id":1e9999999}`, "float64"},
{"wrong type", `{"id":"nope"}`, `want "integer"`},
} {
t.Run(tt.name, func(t *testing.T) {
_, err := applySchema(json.RawMessage(tt.data), resolved, false)
if err == nil {
t.Fatalf("applySchema(%s) succeeded, want error", tt.data)
}
if !strings.Contains(err.Error(), tt.want) {
t.Errorf("applySchema(%s) error = %v, want it to contain %q", tt.data, err, tt.want)
}
})
}
}
Loading