// Copyright 2025 The Go MCP SDK Authors. All rights reserved. // Use of this source code is governed by an MIT-style // license that can be found in the LICENSE file. package jsonschema_test import ( "log/slog" "math/big" "reflect" "strings" "testing" "time" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" "github.com/modelcontextprotocol/go-sdk/jsonschema" ) type custom int func forType[T any](ignore bool) *jsonschema.Schema { var s *jsonschema.Schema var err error opts := &jsonschema.ForOptions{ IgnoreInvalidTypes: ignore, TypeSchemas: map[any]*jsonschema.Schema{ custom(0): {Type: "custom"}, }, } s, err = jsonschema.For[T](opts) if err != nil { panic(err) } return s } func TestFor(t *testing.T) { type schema = jsonschema.Schema type S struct { B int `jsonschema:"bdesc"` } type test struct { name string got *jsonschema.Schema want *jsonschema.Schema } tests := func(ignore bool) []test { return []test{ {"string", forType[string](ignore), &schema{Type: "string"}}, {"int", forType[int](ignore), &schema{Type: "integer"}}, {"int16", forType[int16](ignore), &schema{Type: "integer"}}, {"uint32", forType[int16](ignore), &schema{Type: "integer"}}, {"float64", forType[float64](ignore), &schema{Type: "number"}}, {"bool", forType[bool](ignore), &schema{Type: "boolean"}}, {"time", forType[time.Time](ignore), &schema{Type: "string"}}, {"level", forType[slog.Level](ignore), &schema{Type: "string"}}, {"bigint", forType[big.Int](ignore), &schema{Types: []string{"null", "string"}}}, {"custom", forType[custom](ignore), &schema{Type: "custom"}}, {"intmap", forType[map[string]int](ignore), &schema{ Type: "object", AdditionalProperties: &schema{Type: "integer"}, }}, {"anymap", forType[map[string]any](ignore), &schema{ Type: "object", AdditionalProperties: &schema{}, }}, { "struct", forType[struct { F int `json:"f" jsonschema:"fdesc"` G []float64 P *bool `jsonschema:"pdesc"` Skip string `json:"-"` NoSkip string `json:",omitempty"` unexported float64 unexported2 int `json:"No"` }](ignore), &schema{ Type: "object", Properties: map[string]*schema{ "f": {Type: "integer", Description: "fdesc"}, "G": {Type: "array", Items: &schema{Type: "number"}}, "P": {Types: []string{"null", "boolean"}, Description: "pdesc"}, "NoSkip": {Type: "string"}, }, Required: []string{"f", "G", "P"}, AdditionalProperties: falseSchema(), }, }, { "no sharing", forType[struct{ X, Y int }](ignore), &schema{ Type: "object", Properties: map[string]*schema{ "X": {Type: "integer"}, "Y": {Type: "integer"}, }, Required: []string{"X", "Y"}, AdditionalProperties: falseSchema(), }, }, { "nested and embedded", forType[struct { A S S }](ignore), &schema{ Type: "object", Properties: map[string]*schema{ "A": { Type: "object", Properties: map[string]*schema{ "B": {Type: "integer", Description: "bdesc"}, }, Required: []string{"B"}, AdditionalProperties: falseSchema(), }, "S": { Type: "object", Properties: map[string]*schema{ "B": {Type: "integer", Description: "bdesc"}, }, Required: []string{"B"}, AdditionalProperties: falseSchema(), }, }, Required: []string{"A", "S"}, AdditionalProperties: falseSchema(), }, }, } } run := func(t *testing.T, tt test) { if diff := cmp.Diff(tt.want, tt.got, cmpopts.IgnoreUnexported(jsonschema.Schema{})); diff != "" { t.Fatalf("For mismatch (-want +got):\n%s", diff) } // These schemas should all resolve. if _, err := tt.got.Resolve(nil); err != nil { t.Fatalf("Resolving: %v", err) } } t.Run("strict", func(t *testing.T) { for _, test := range tests(false) { t.Run(test.name, func(t *testing.T) { run(t, test) }) } }) laxTests := append(tests(true), test{ "ignore", forType[struct { A int B map[int]int C func() }](true), &schema{ Type: "object", Properties: map[string]*schema{ "A": {Type: "integer"}, }, Required: []string{"A"}, AdditionalProperties: falseSchema(), }, }) t.Run("lax", func(t *testing.T) { for _, test := range laxTests { t.Run(test.name, func(t *testing.T) { run(t, test) }) } }) } func TestForType(t *testing.T) { type schema = jsonschema.Schema // ForType is virtually identical to For. Just test that options are handled properly. opts := &jsonschema.ForOptions{ IgnoreInvalidTypes: true, TypeSchemas: map[any]*jsonschema.Schema{ custom(0): {Type: "custom"}, }, } type S struct { I int F func() C custom } got, err := jsonschema.ForType(reflect.TypeOf(S{}), opts) if err != nil { t.Fatal(err) } want := &schema{ Type: "object", Properties: map[string]*schema{ "I": {Type: "integer"}, "C": {Type: "custom"}, }, Required: []string{"I", "C"}, AdditionalProperties: falseSchema(), } if diff := cmp.Diff(want, got, cmpopts.IgnoreUnexported(schema{})); diff != "" { t.Fatalf("ForType mismatch (-want +got):\n%s", diff) } } func forErr[T any]() error { _, err := jsonschema.For[T](nil) return err } func TestForErrors(t *testing.T) { type ( s1 struct { Empty int `jsonschema:""` } s2 struct { Bad int `jsonschema:"$foo=1,bar"` } ) for _, tt := range []struct { got error want string }{ {forErr[map[int]int](), "unsupported map key type"}, {forErr[s1](), "empty jsonschema tag"}, {forErr[s2](), "must not begin with"}, {forErr[func()](), "unsupported"}, } { if tt.got == nil { t.Errorf("got nil, want error containing %q", tt.want) } else if !strings.Contains(tt.got.Error(), tt.want) { t.Errorf("got %q\nwant it to contain %q", tt.got, tt.want) } } } func TestForWithMutation(t *testing.T) { // This test ensures that the cached schema is not mutated when the caller // mutates the returned schema. type S struct { A int } type T struct { A int `json:"A"` B map[string]int C []S D [3]S E *bool } s, err := jsonschema.For[T](nil) if err != nil { t.Fatalf("For: %v", err) } s.Required[0] = "mutated" s.Properties["A"].Type = "mutated" s.Properties["C"].Items.Type = "mutated" s.Properties["D"].MaxItems = jsonschema.Ptr(10) s.Properties["D"].MinItems = jsonschema.Ptr(10) s.Properties["E"].Types[0] = "mutated" s2, err := jsonschema.For[T](nil) if err != nil { t.Fatalf("For: %v", err) } if s2.Properties["A"].Type == "mutated" { t.Fatalf("ForWithMutation: expected A.Type to not be mutated") } if s2.Properties["B"].AdditionalProperties.Type == "mutated" { t.Fatalf("ForWithMutation: expected B.AdditionalProperties.Type to not be mutated") } if s2.Properties["C"].Items.Type == "mutated" { t.Fatalf("ForWithMutation: expected C.Items.Type to not be mutated") } if *s2.Properties["D"].MaxItems == 10 { t.Fatalf("ForWithMutation: expected D.MaxItems to not be mutated") } if *s2.Properties["D"].MinItems == 10 { t.Fatalf("ForWithMutation: expected D.MinItems to not be mutated") } if s2.Properties["E"].Types[0] == "mutated" { t.Fatalf("ForWithMutation: expected E.Types[0] to not be mutated") } if s2.Required[0] == "mutated" { t.Fatalf("ForWithMutation: expected Required[0] to not be mutated") } } type x struct { Y y } type y struct { X []x } func TestForWithCycle(t *testing.T) { type a []*a type b1 struct{ b *b1 } // unexported field should be skipped type b2 struct{ B *b2 } type c1 struct{ c map[string]*c1 } // unexported field should be skipped type c2 struct{ C map[string]*c2 } tests := []struct { name string shouldErr bool fn func() error }{ {"slice alias (a)", true, func() error { _, err := jsonschema.For[a](nil); return err }}, {"unexported self cycle (b1)", false, func() error { _, err := jsonschema.For[b1](nil); return err }}, {"exported self cycle (b2)", true, func() error { _, err := jsonschema.For[b2](nil); return err }}, {"unexported map self cycle (c1)", false, func() error { _, err := jsonschema.For[c1](nil); return err }}, {"exported map self cycle (c2)", true, func() error { _, err := jsonschema.For[c2](nil); return err }}, {"cross-cycle x -> y -> x", true, func() error { _, err := jsonschema.For[x](nil); return err }}, {"cross-cycle y -> x -> y", true, func() error { _, err := jsonschema.For[y](nil); return err }}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { err := test.fn() if test.shouldErr && err == nil { t.Errorf("expected cycle error, got nil") } if !test.shouldErr && err != nil { t.Errorf("unexpected error: %v", err) } }) } } func falseSchema() *jsonschema.Schema { return &jsonschema.Schema{Not: &jsonschema.Schema{}} }