diff --git a/internal/config/config_stdin.go b/internal/config/config_stdin.go index b93c37c59..ebf6ab226 100644 --- a/internal/config/config_stdin.go +++ b/internal/config/config_stdin.go @@ -3,6 +3,7 @@ package config import ( + "bytes" "encoding/json" "fmt" "io" @@ -10,6 +11,7 @@ import ( "os" "github.com/github/gh-aw-mcpg/internal/logger" + "github.com/santhosh-tekuri/jsonschema/v6" ) var logStdin = logger.New("config:config_stdin") @@ -205,10 +207,18 @@ func (s *StdinServerConfig) UnmarshalJSON(data []byte) error { return err } - // Now unmarshal into a map to capture all fields - var allFields map[string]interface{} - if err := json.Unmarshal(data, &allFields); err != nil { - return err + // Now unmarshal into a map to capture all fields. + // Use jsonschema.UnmarshalJSON (which calls decoder.UseNumber()) so that + // numbers are stored as json.Number rather than float64. This preserves + // precision for large integers such as 9007199254740993 that cannot be + // represented exactly as float64. + allFieldsObj, parseErr := jsonschema.UnmarshalJSON(bytes.NewReader(data)) + if parseErr != nil { + return parseErr + } + allFields, ok := allFieldsObj.(map[string]interface{}) + if !ok { + return fmt.Errorf("expected JSON object for server config, got %T", allFieldsObj) } // Known fields in the struct diff --git a/internal/config/config_stdin_unmarshal_coverage_test.go b/internal/config/config_stdin_unmarshal_coverage_test.go index 7c791542b..eb7315abd 100644 --- a/internal/config/config_stdin_unmarshal_coverage_test.go +++ b/internal/config/config_stdin_unmarshal_coverage_test.go @@ -1,6 +1,7 @@ package config import ( + "encoding/json" "testing" "github.com/stretchr/testify/assert" @@ -154,7 +155,9 @@ func TestStdinServerConfig_UnmarshalJSON_ErrorPaths(t *testing.T) { err := server.UnmarshalJSON(data) require.NoError(t, err) assert.Equal(t, "customValue", server.AdditionalProperties["customField"]) - assert.Equal(t, float64(42), server.AdditionalProperties["anotherExtra"]) + // Numbers are stored as json.Number (not float64) to preserve precision for + // large integers such as 9007199254740993 that cannot be represented by float64. + assert.Equal(t, json.Number("42"), server.AdditionalProperties["anotherExtra"]) // Known fields must not appear in AdditionalProperties. _, typeExists := server.AdditionalProperties["type"] assert.False(t, typeExists, "known field 'type' should not appear in AdditionalProperties") diff --git a/internal/config/validate_server_against_schema_test.go b/internal/config/validate_server_against_schema_test.go index 236a182b6..dba7224e7 100644 --- a/internal/config/validate_server_against_schema_test.go +++ b/internal/config/validate_server_against_schema_test.go @@ -239,3 +239,43 @@ func TestValidateServerAgainstSchema_TypeMismatchInAdditionalProperties(t *testi "wrong type for an additional property should cause a schema validation error") assert.Contains(t, err.Error(), "does not match custom schema") } + +// TestValidateServerAgainstSchema_LargeIntegerPrecision is a regression test that +// verifies large integers (beyond float64 safe range) in additional properties are +// preserved with full precision throughout the validation path. +// +// 9007199254740993 == 2^53+1 cannot be represented exactly as float64 +// (it rounds to 9007199254740992). If UnmarshalJSON uses encoding/json the +// const constraint below would fail even though the original JSON is correct. +func TestValidateServerAgainstSchema_LargeIntegerPrecision(t *testing.T) { + const largeInt = `9007199254740993` + + // Schema constrains "seq-id" to the exact large-integer value. + schema := compileSchemaForTest(t, `{ + "type": "object", + "required": ["seq-id"], + "properties": { + "seq-id": {"const": `+largeInt+`} + } + }`) + + // Parse the server config through the real UnmarshalJSON path so that the + // large integer travels through StdinServerConfig.UnmarshalJSON and lands + // in AdditionalProperties. + serverJSON := []byte(`{ + "type": "stdio", + "container": "ghcr.io/example/server:latest", + "seq-id": ` + largeInt + ` + }`) + + var server StdinServerConfig + require.NoError(t, server.UnmarshalJSON(serverJSON)) + + err := validateServerAgainstSchema( + "test-server", &server, schema, + "https://test.example.com/schema.json", + "mcpServers.test-server", + ) + assert.NoError(t, err, + "large integer 9007199254740993 must be preserved without float64 rounding") +} diff --git a/internal/config/validation_schema.go b/internal/config/validation_schema.go index 2e6ef3bd1..735a2ab7f 100644 --- a/internal/config/validation_schema.go +++ b/internal/config/validation_schema.go @@ -249,7 +249,7 @@ func formatSchemaError(err error) error { AppendConfigDocsFooter(&sb) - return fmt.Errorf("%s", sb.String()) + return errors.New(sb.String()) } return fmt.Errorf("configuration validation error (version: %s): %s", version.Get(), err.Error()) diff --git a/internal/config/validation_server.go b/internal/config/validation_server.go index d9c54bc06..d55802826 100644 --- a/internal/config/validation_server.go +++ b/internal/config/validation_server.go @@ -302,7 +302,7 @@ func validateAgainstCustomSchema(name string, server *StdinServerConfig, schemaU if err != nil { return schemaErr( fmt.Sprintf("failed to compile custom schema: %v", err), - fmt.Sprintf("The schema at '%s' must be a valid JSON Schema Draft 7 document", schemaURL)) + fmt.Sprintf("The schema at '%s' must be a valid JSON Schema document (Draft 2020-12 or earlier)", schemaURL)) } logValidation.Printf("Custom schema compiled successfully: name=%s", name) @@ -330,12 +330,18 @@ func validateServerAgainstSchema(name string, server *StdinServerConfig, schema "Internal error - please report this issue") } - // Unmarshal to map to get struct fields - if err := json.Unmarshal(serverJSON, &serverMap); err != nil { + // Parse using jsonschema.UnmarshalJSON for number-precision consistency with embedded schema path + serverObj, parseErr := jsonschema.UnmarshalJSON(bytes.NewReader(serverJSON)) + if parseErr != nil { return schemaErr( - fmt.Sprintf("failed to unmarshal server config for validation: %v", err), + fmt.Sprintf("failed to parse server config for validation: %v", parseErr), "Internal error - please report this issue") } + if obj, ok := serverObj.(map[string]interface{}); ok { + serverMap = obj + } else { + logValidation.Printf("unexpected: server config parsed to non-object type, using empty map for validation: name=%s", name) + } // Merge additional properties (custom fields) into the map for key, value := range server.AdditionalProperties {