diff --git a/internal/json/json.go b/internal/json/json.go index b3fa039b..8031101d 100644 --- a/internal/json/json.go +++ b/internal/json/json.go @@ -37,6 +37,12 @@ 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 @@ -44,6 +50,16 @@ func Unmarshal(data []byte, v any) error { 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. diff --git a/mcp/server_test.go b/mcp/server_test.go index 11e75e23..e94da50f 100644 --- a/mcp/server_test.go +++ b/mcp/server_test.go @@ -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) + } +} diff --git a/mcp/tool.go b/mcp/tool.go index 11ad100e..ec56ad8f 100644 --- a/mcp/tool.go +++ b/mcp/tool.go @@ -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) } } @@ -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 } @@ -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. diff --git a/mcp/tool_test.go b/mcp/tool_test.go index 7a82c96d..b3abf84b 100644 --- a/mcp/tool_test.go +++ b/mcp/tool_test.go @@ -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) + } + }) + } +}