From 3434e57007c12d6a7711ee99d10d393671250f57 Mon Sep 17 00:00:00 2001 From: Jonathan Amsterdam Date: Thu, 24 Apr 2025 11:40:50 -0400 Subject: [PATCH] internal/mcp/internal/jsonschema: schema definition The first of several CLs providing JSON Schema validation. Replace the existing Schema type with one that has all the keywords of the most current version of the JSON Schema spec, called 2020-12. The 2020-12 spec can be represented type-safely in Go with one exception: the "type" keyword can be either a string or a []string. To handle this safely, both Type and Types fields are defined. Custom JSONMarshal/Unmarshal methods handle the conversion. We need the unmarshal anyway because "true" and "false" are valid schemas. Unfortunately, the MCP spec uses the older "draft7" version of JSON Schema, but the two are close enough for code generation to work. I confirmed that the protocol generator produces the identical output. Change-Id: I44f6ea2f3a3f8498979946f69dfb7f50afa1acad Reviewed-on: https://go-review.googlesource.com/c/tools/+/667855 LUCI-TryBot-Result: Go LUCI Reviewed-by: Alan Donovan --- internal/jsonschema/infer.go | 118 ++++++++++++ internal/jsonschema/infer_test.go | 70 ++++++++ internal/jsonschema/schema.go | 280 ++++++++++++++++++----------- internal/jsonschema/schema_test.go | 81 +++------ internal/jsonschema/util.go | 162 +++++++++++++++++ internal/jsonschema/util_test.go | 39 ++++ internal/protocol/generate.go | 28 +-- mcp/mcp_test.go | 7 +- mcp/tool.go | 2 +- 9 files changed, 612 insertions(+), 175 deletions(-) create mode 100644 internal/jsonschema/infer.go create mode 100644 internal/jsonschema/infer_test.go create mode 100644 internal/jsonschema/util.go create mode 100644 internal/jsonschema/util_test.go diff --git a/internal/jsonschema/infer.go b/internal/jsonschema/infer.go new file mode 100644 index 0000000..34309be --- /dev/null +++ b/internal/jsonschema/infer.go @@ -0,0 +1,118 @@ +// 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. + +// This file contains functions that infer a schema from a Go type. + +package jsonschema + +import ( + "fmt" + "reflect" + "strings" +) + +// For constructs a JSON schema object for the given type argument. +// +// It is a convenience for ForType. +func For[T any]() (*Schema, error) { + return ForType(reflect.TypeFor[T]()) +} + +// It returns an error if t contains (possibly recursively) any of the following Go +// types, as they are incompatible with the JSON schema spec. +// - maps with key other than 'string' +// - function types +// - complex numbers +// - unsafe pointers +// +// TODO(rfindley): we could perhaps just skip these incompatible fields. +func ForType(t reflect.Type) (*Schema, error) { + return typeSchema(t, make(map[reflect.Type]*Schema)) +} + +func typeSchema(t reflect.Type, seen map[reflect.Type]*Schema) (*Schema, error) { + if t.Kind() == reflect.Pointer { + t = t.Elem() + } + if s := seen[t]; s != nil { + return s, nil + } + var ( + s = new(Schema) + err error + ) + seen[t] = s + + switch t.Kind() { + case reflect.Bool: + s.Type = "boolean" + + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, + reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, + reflect.Uintptr: + s.Type = "integer" + + case reflect.Float32, reflect.Float64: + s.Type = "number" + + case reflect.Interface: + // Unrestricted + + case reflect.Map: + if t.Key().Kind() != reflect.String { + return nil, fmt.Errorf("unsupported map key type %v", t.Key().Kind()) + } + s.Type = "object" + s.AdditionalProperties, err = typeSchema(t.Elem(), seen) + if err != nil { + return nil, fmt.Errorf("computing map value schema: %v", err) + } + + case reflect.Slice, reflect.Array: + s.Type = "array" + s.Items, err = typeSchema(t.Elem(), seen) + if err != nil { + return nil, fmt.Errorf("computing element schema: %v", err) + } + + case reflect.String: + s.Type = "string" + + case reflect.Struct: + s.Type = "object" + // no additional properties are allowed + s.AdditionalProperties = &Schema{Not: &Schema{}} + + for i := range t.NumField() { + field := t.Field(i) + name, ok := jsonName(field) + if !ok { + continue + } + if s.Properties == nil { + s.Properties = make(map[string]*Schema) + } + s.Properties[name], err = typeSchema(field.Type, seen) + if err != nil { + return nil, err + } + } + + default: + return nil, fmt.Errorf("type %v is unsupported by jsonschema", t) + } + return s, nil +} + +func jsonName(f reflect.StructField) (string, bool) { + if !f.IsExported() { + return "", false + } + if tag, ok := f.Tag.Lookup("json"); ok { + if name, _, _ := strings.Cut(tag, ","); name != "" { + return name, name != "-" + } + } + return f.Name, true +} diff --git a/internal/jsonschema/infer_test.go b/internal/jsonschema/infer_test.go new file mode 100644 index 0000000..a77c15e --- /dev/null +++ b/internal/jsonschema/infer_test.go @@ -0,0 +1,70 @@ +// 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 ( + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/modelcontextprotocol/go-sdk/internal/jsonschema" +) + +func forType[T any]() *jsonschema.Schema { + s, err := jsonschema.For[T]() + if err != nil { + panic(err) + } + return s +} + +func TestForType(t *testing.T) { + type schema = jsonschema.Schema + tests := []struct { + name string + got *jsonschema.Schema + want *jsonschema.Schema + }{ + {"string", forType[string](), &schema{Type: "string"}}, + {"int", forType[int](), &schema{Type: "integer"}}, + {"int16", forType[int16](), &schema{Type: "integer"}}, + {"uint32", forType[int16](), &schema{Type: "integer"}}, + {"float64", forType[float64](), &schema{Type: "number"}}, + {"bool", forType[bool](), &schema{Type: "boolean"}}, + {"intmap", forType[map[string]int](), &schema{ + Type: "object", + AdditionalProperties: &schema{Type: "integer"}, + }}, + {"anymap", forType[map[string]any](), &schema{ + Type: "object", + AdditionalProperties: &schema{}, + }}, + {"struct", forType[struct { + F int `json:"f"` + G []float64 + P *bool + Skip string `json:"-"` + NoSkip string `json:",omitempty"` + unexported float64 + unexported2 int `json:"No"` + }](), &schema{ + Type: "object", + Properties: map[string]*schema{ + "f": {Type: "integer"}, + "G": {Type: "array", Items: &schema{Type: "number"}}, + "P": {Type: "boolean"}, + "NoSkip": {Type: "string"}, + }, + AdditionalProperties: &jsonschema.Schema{Not: &jsonschema.Schema{}}, + }}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if diff := cmp.Diff(test.want, test.got); diff != "" { + t.Errorf("ForType mismatch (-want +got):\n%s", diff) + } + }) + } +} diff --git a/internal/jsonschema/schema.go b/internal/jsonschema/schema.go index ca6923d..af1dbc2 100644 --- a/internal/jsonschema/schema.go +++ b/internal/jsonschema/schema.go @@ -2,129 +2,193 @@ // Use of this source code is governed by an MIT-style // license that can be found in the LICENSE file. +// Package jsonschema is an implementation of the JSON Schema +// specification: https://json-schema.org. package jsonschema import ( + "bytes" + "encoding/json" + "errors" "fmt" - "reflect" - "strings" ) // A Schema is a JSON schema object. -// -// Right now, Schemas are only used for JSON serialization. In the future, they -// should support validation. +// It corresponds to the 2020-12 draft, as described in +// https://json-schema.org/draft/2020-12. type Schema struct { - Definitions map[string]*Schema `json:"definitions"` - Type any `json:"type,omitempty"` - Ref string `json:"$ref,omitempty"` - Description string `json:"description,omitempty"` - Properties map[string]*Schema `json:"properties,omitempty"` - Required []string `json:"required,omitempty"` - Items *Schema `json:"items,omitempty"` - AdditionalProperties any `json:"additionalProperties,omitempty"` + // core + ID string `json:"$id,omitempty"` + Schema string `json:"$schema,omitempty"` + Ref string `json:"$ref,omitempty"` + Comment string `json:"$comment,omitempty"` + Defs map[string]*Schema `json:"$defs,omitempty"` + // definitions is deprecated but still allowed. It is a synonym for defs. + Definitions map[string]*Schema `json:"definitions,omitempty"` + + Anchor string `json:"$anchor,omitempty"` + DynamicAnchor string `json:"$dynamicAnchor,omitempty"` + DynamicRef string `json:"$dynamicRef,omitempty"` + Vocabulary map[string]bool `json:"$vocabulary,omitempty"` + + // metadata + Title string `json:"title,omitempty"` + Description string `json:"description,omitempty"` + + // validation + // Use Type for a single type, or Types for multiple types; never both. + Type string `json:"-"` + Types []string `json:"-"` + Enum []any `json:"enum,omitempty"` + // Const is *any because a JSON null (Go nil) is a valid value. + Const *any `json:"const,omitempty"` + MultipleOf *float64 `json:"multipleOf,omitempty"` + Minimum *float64 `json:"minimum,omitempty"` + Maximum *float64 `json:"maximum,omitempty"` + ExclusiveMinimum *float64 `json:"exclusiveMinimum,omitempty"` + ExclusiveMaximum *float64 `json:"exclusiveMaximum,omitempty"` + MinLength *float64 `json:"minLength,omitempty"` + MaxLength *float64 `json:"maxLength,omitempty"` + Pattern string `json:"pattern,omitempty"` + + // arrays + PrefixItems []*Schema `json:"prefixItems,omitempty"` + Items *Schema `json:"items,omitempty"` + MinItems *float64 `json:"minItems,omitempty"` + MaxItems *float64 `json:"maxItems,omitempty"` + AdditionalItems *Schema `json:"additionalItems,omitempty"` + UniqueItems bool `json:"uniqueItems,omitempty"` + Contains *Schema `json:"contains,omitempty"` + MinContains *float64 `json:"minContains,omitempty"` + MaxContains *float64 `json:"maxContains,omitempty"` + UnevaluatedItems *Schema `json:"unevaluatedItems,omitempty"` + + // objects + MinProperties *float64 `json:"minProperties,omitempty"` + MaxProperties *float64 `json:"maxProperties,omitempty"` + Required []string `json:"required,omitempty"` + DependentRequired map[string][]string `json:"dependentRequired,omitempty"` + Properties map[string]*Schema `json:"properties,omitempty"` + PatternProperties map[string]*Schema `json:"patternProperties,omitempty"` + AdditionalProperties *Schema `json:"additionalProperties,omitempty"` + PropertyNames *Schema `json:"propertyNames,omitempty"` + UnevaluatedProperties *Schema `json:"unevaluatedProperties,omitempty"` + + // logic + AllOf []*Schema `json:"allOf,omitempty"` + AnyOf []*Schema `json:"anyOf,omitempty"` + OneOf []*Schema `json:"oneOf,omitempty"` + Not *Schema `json:"not,omitempty"` + + // conditional + If *Schema `json:"if,omitempty"` + Then *Schema `json:"then,omitempty"` + Else *Schema `json:"else,omitempty"` + DependentSchemas map[string]*Schema `json:"dependentSchemas,omitempty"` } -// ForType constructs a JSON schema object for the given type argument. -// -// The type T must not contain (possibly recursively) any of the following Go -// types, as they are incompatible with the JSON schema spec. -// - maps with key other than 'string' -// - function types -// - complex numbers -// - unsafe pointers -// -// TODO(rfindley): we could perhaps just skip these incompatible fields. -func ForType[T any]() (*Schema, error) { - return typeSchema(reflect.TypeFor[T](), make(map[reflect.Type]*Schema)) +// String returns a short description of the schema. +func (s *Schema) String() string { + if s.ID != "" { + return s.ID + } + // TODO: return something better, like a JSON Pointer from the base. + return "" } -func typeSchema(t reflect.Type, seen map[reflect.Type]*Schema) (*Schema, error) { - if s := seen[t]; s != nil { - return s, nil +// json returns the schema in json format. +func (s *Schema) json() string { + data, err := json.MarshalIndent(s, "", " ") + if err != nil { + return fmt.Sprintf("", err) } - var ( - s = new(Schema) - err error - ) - seen[t] = s - - switch t.Kind() { - case reflect.Bool: - s.Type = "boolean" - - case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, - reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: - s.Type = "integer" - - case reflect.Float32, reflect.Float64: - s.Type = "number" - - case reflect.Interface: - // Unrestricted - - case reflect.Map: - if t.Key().Kind() != reflect.String { - return nil, fmt.Errorf("unsupported map key type %v", t.Key().Kind()) - } - s.Type = "object" - valueSchema, err := typeSchema(t.Elem(), seen) - if err != nil { - return nil, fmt.Errorf("computing map value schema: %v", err) - } - s.AdditionalProperties = valueSchema - - case reflect.Pointer: - s2, err := typeSchema(t.Elem(), seen) - if err != nil { - return nil, err - } - *s = *s2 - - case reflect.Slice, reflect.Array: - s.Type = "array" - itemSchema, err := typeSchema(t.Elem(), seen) - if err != nil { - return nil, fmt.Errorf("computing element schema: %v", err) - } - s.Items = itemSchema - - case reflect.String: - s.Type = "string" - - case reflect.Struct: - s.Type = "object" - s.AdditionalProperties = false - - for i := range t.NumField() { - if s.Properties == nil { - s.Properties = make(map[string]*Schema) - } - rfld := t.Field(i) - name, ok := jsonName(rfld) - if !ok { - continue - } - s.Properties[name], err = typeSchema(rfld.Type, seen) - if err != nil { - return nil, err - } - } - - default: - return nil, fmt.Errorf("type %v is unsupported by jsonschema", t.Kind()) - } - return s, nil + return string(data) } -func jsonName(f reflect.StructField) (string, bool) { - j, ok := f.Tag.Lookup("json") - if !ok { - return f.Name, f.IsExported() +func (s *Schema) basicChecks() error { + if s.Type != "" && s.Types != nil { + return errors.New("both Type and Types are set; at most one should be") } - name, _, _ := strings.Cut(j, ",") - if name == "" { - return f.Name, f.IsExported() + if s.Defs != nil && s.Definitions != nil { + return errors.New("both Defs and Definitions are set; at most one should be") } - return name, name != "" && name != "-" + return nil } + +type schemaWithoutMethods Schema // doesn't implement json.{Unm,M}arshaler + +func (s *Schema) MarshalJSON() ([]byte, error) { + if err := s.basicChecks(); err != nil { + return nil, err + } + // Marshal either Type or Types as "type". + var typ any + switch { + case s.Type != "": + typ = s.Type + case s.Types != nil: + typ = s.Types + } + ms := struct { + Type any `json:"type,omitempty"` + *schemaWithoutMethods + }{ + Type: typ, + schemaWithoutMethods: (*schemaWithoutMethods)(s), + } + return json.Marshal(ms) +} + +func (s *Schema) UnmarshalJSON(data []byte) error { + // A JSON boolean is a valid schema. + var b bool + if err := json.Unmarshal(data, &b); err == nil { + if b { + // true is the empty schema, which validates everything. + *s = Schema{} + } else { + // false is the schema that validates nothing. + *s = Schema{Not: &Schema{}} + } + return nil + } + + ms := struct { + Type json.RawMessage `json:"type,omitempty"` + Const json.RawMessage `json:"const,omitempty"` + *schemaWithoutMethods + }{ + schemaWithoutMethods: (*schemaWithoutMethods)(s), + } + if err := json.Unmarshal(data, &ms); err != nil { + return err + } + // Unmarshal "type" as either Type or Types. + var err error + if len(ms.Type) > 0 { + switch ms.Type[0] { + case '"': + err = json.Unmarshal(ms.Type, &s.Type) + case '[': + err = json.Unmarshal(ms.Type, &s.Types) + default: + err = fmt.Errorf("invalid type: %q", ms.Type) + } + } + if err != nil { + return err + } + // Setting Const to a pointer to null will marshal properly, but won't unmarshal: + // the *any is set to nil, not a pointer to nil. + if len(ms.Const) > 0 { + if bytes.Equal(ms.Const, []byte("null")) { + s.Const = new(any) + } else if err := json.Unmarshal(ms.Const, &s.Const); err != nil { + return err + } + } + return nil +} + +// Ptr returns a pointer to a new variable whose value is x. +func Ptr[T any](x T) *T { return &x } diff --git a/internal/jsonschema/schema_test.go b/internal/jsonschema/schema_test.go index 302c9b7..2374aa0 100644 --- a/internal/jsonschema/schema_test.go +++ b/internal/jsonschema/schema_test.go @@ -2,64 +2,39 @@ // Use of this source code is governed by an MIT-style // license that can be found in the LICENSE file. -package jsonschema_test +package jsonschema import ( + "encoding/json" "testing" - - "github.com/google/go-cmp/cmp" - "github.com/modelcontextprotocol/go-sdk/internal/jsonschema" ) -func forType[T any]() *jsonschema.Schema { - s, err := jsonschema.ForType[T]() - if err != nil { - panic(err) - } - return s -} - -func TestForType(t *testing.T) { - type schema = jsonschema.Schema - tests := []struct { - name string - got *jsonschema.Schema - want *jsonschema.Schema - }{ - {"string", forType[string](), &schema{Type: "string"}}, - {"int", forType[int](), &schema{Type: "integer"}}, - {"int16", forType[int16](), &schema{Type: "integer"}}, - {"uint32", forType[int16](), &schema{Type: "integer"}}, - {"float64", forType[float64](), &schema{Type: "number"}}, - {"bool", forType[bool](), &schema{Type: "boolean"}}, - {"intmap", forType[map[string]int](), &schema{ - Type: "object", - AdditionalProperties: &schema{Type: "integer"}, - }}, - {"anymap", forType[map[string]any](), &schema{ - Type: "object", - AdditionalProperties: &schema{}, - }}, - {"struct", forType[struct { - F int `json:"f"` - G []float64 - Skip string `json:"-"` - unexported float64 - }](), &schema{ - Type: "object", - Properties: map[string]*schema{ - "f": {Type: "integer"}, - "G": {Type: "array", Items: &schema{Type: "number"}}, - }, - AdditionalProperties: false, - }}, - } - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - if diff := cmp.Diff(test.want, test.got); diff != "" { - t.Errorf("ForType mismatch (-want +got):\n%s", diff) +func TestMarshal(t *testing.T) { + for _, s := range []*Schema{ + {Type: "null"}, + {Types: []string{"null", "number"}}, + {Type: "string", MinLength: Ptr(20.0)}, + {Minimum: Ptr(20.0)}, + {Items: &Schema{Type: "integer"}}, + {Const: Ptr(any(0))}, + {Const: Ptr(any(nil))}, + {Const: Ptr(any([]int{}))}, + {Const: Ptr(any(map[string]any{}))}, + } { + data, err := json.Marshal(s) + if err != nil { + t.Fatal(err) + } + t.Logf("marshal: %s", data) + var got *Schema + if err := json.Unmarshal(data, &got); err != nil { + t.Fatal(err) + } + if !Equal(got, s) { + t.Errorf("got %+v, want %+v", got, s) + if got.Const != nil && s.Const != nil { + t.Logf("Consts: got %#v (%[1]T), want %#v (%[2]T)", *got.Const, *s.Const) } - }) + } } } diff --git a/internal/jsonschema/util.go b/internal/jsonschema/util.go new file mode 100644 index 0000000..2dc5bb6 --- /dev/null +++ b/internal/jsonschema/util.go @@ -0,0 +1,162 @@ +// 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 + +import ( + "bytes" + "encoding/json" + "fmt" + "math/big" + "reflect" +) + +// Equal reports whether two Go values representing JSON values are equal according +// to the JSON Schema spec. +// The values must not contain cycles. +// See https://json-schema.org/draft/2020-12/json-schema-core#section-4.2.2. +// It behaves like reflect.DeepEqual, except that numbers are compared according +// to mathematical equality. +func Equal(x, y any) bool { + return equalValue(reflect.ValueOf(x), reflect.ValueOf(y)) +} + +func equalValue(x, y reflect.Value) bool { + // Copied from src/reflect/deepequal.go, omitting the visited check (because JSON + // values are trees). + if !x.IsValid() || !y.IsValid() { + return x.IsValid() == y.IsValid() + } + + // Treat numbers specially. + rx, ok1 := jsonNumber(x) + ry, ok2 := jsonNumber(y) + if ok1 && ok2 { + return rx.Cmp(ry) == 0 + } + if x.Kind() != y.Kind() { + return false + } + switch x.Kind() { + case reflect.Array: + if x.Len() != y.Len() { + return false + } + for i := range x.Len() { + if !equalValue(x.Index(i), y.Index(i)) { + return false + } + } + return true + case reflect.Slice: + if x.IsNil() != y.IsNil() { + return false + } + if x.Len() != y.Len() { + return false + } + if x.UnsafePointer() == y.UnsafePointer() { + return true + } + // Special case for []byte, which is common. + if x.Type().Elem().Kind() == reflect.Uint8 && x.Type() == y.Type() { + return bytes.Equal(x.Bytes(), y.Bytes()) + } + for i := range x.Len() { + if !equalValue(x.Index(i), y.Index(i)) { + return false + } + } + return true + case reflect.Interface: + if x.IsNil() || y.IsNil() { + return x.IsNil() == y.IsNil() + } + return equalValue(x.Elem(), y.Elem()) + case reflect.Pointer: + if x.UnsafePointer() == y.UnsafePointer() { + return true + } + return equalValue(x.Elem(), y.Elem()) + case reflect.Struct: + t := x.Type() + if t != y.Type() { + return false + } + for i := range t.NumField() { + sf := t.Field(i) + if !sf.IsExported() { + continue + } + if !equalValue(x.FieldByIndex(sf.Index), y.FieldByIndex(sf.Index)) { + return false + } + } + return true + case reflect.Map: + if x.IsNil() != y.IsNil() { + return false + } + if x.Len() != y.Len() { + return false + } + if x.UnsafePointer() == y.UnsafePointer() { + return true + } + iter := x.MapRange() + for iter.Next() { + vx := iter.Value() + vy := y.MapIndex(iter.Key()) + if !vy.IsValid() || !equalValue(vx, vy) { + return false + } + } + return true + case reflect.Func: + if x.Type() != y.Type() { + return false + } + if x.IsNil() && y.IsNil() { + return true + } + panic("cannot compare functions") + case reflect.String: + return x.String() == y.String() + case reflect.Bool: + return x.Bool() == y.Bool() + case reflect.Complex64, reflect.Complex128: + return x.Complex() == y.Complex() + // Ints, uints and floats handled in jsonNumber, at top of function. + default: + panic(fmt.Sprintf("unsupported kind: %s", x.Kind())) + } +} + +// jsonNumber converts a numeric value or a json.Number to a [big.Rat]. +// If v is not a number, it returns nil, false. +func jsonNumber(v reflect.Value) (*big.Rat, bool) { + r := new(big.Rat) + switch { + case !v.IsValid(): + return nil, false + case v.CanInt(): + r.SetInt64(v.Int()) + case v.CanUint(): + r.SetUint64(v.Uint()) + case v.CanFloat(): + r.SetFloat64(v.Float()) + default: + jn, ok := v.Interface().(json.Number) + if !ok { + return nil, false + } + if _, ok := r.SetString(jn.String()); !ok { + // This can fail in rare cases; for example, "1e9999999". + // That is a valid JSON number, since the spec puts no limit on the size + // of the exponent. + return nil, false + } + } + return r, true +} diff --git a/internal/jsonschema/util_test.go b/internal/jsonschema/util_test.go new file mode 100644 index 0000000..b7d7e1f --- /dev/null +++ b/internal/jsonschema/util_test.go @@ -0,0 +1,39 @@ +// 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 + +import "testing" + +func TestEqual(t *testing.T) { + for _, tt := range []struct { + x1, x2 any + want bool + }{ + {0, 1, false}, + {1, 1.0, true}, + {nil, 0, false}, + {"0", 0, false}, + {2.5, 2.5, true}, + {[]int{1, 2}, []float64{1.0, 2.0}, true}, + {[]int(nil), []int{}, false}, + {[]map[string]any(nil), []map[string]any{}, false}, + { + map[string]any{"a": 1, "b": 2.0}, + map[string]any{"a": 1.0, "b": 2}, + true, + }, + } { + check := func(x1, x2 any, want bool) { + t.Helper() + if got := Equal(x1, x2); got != want { + t.Errorf("jsonEqual(%#v, %#v) = %t, want %t", x1, x2, got, want) + } + } + check(tt.x1, tt.x1, true) + check(tt.x2, tt.x2, true) + check(tt.x1, tt.x2, tt.want) + check(tt.x2, tt.x1, tt.want) + } +} diff --git a/internal/protocol/generate.go b/internal/protocol/generate.go index d0697ee..7c9163a 100644 --- a/internal/protocol/generate.go +++ b/internal/protocol/generate.go @@ -23,6 +23,7 @@ import ( "log" "net/http" "os" + "reflect" "slices" "strings" @@ -240,14 +241,16 @@ func writeType(w io.Writer, config *typeConfig, def *jsonschema.Schema, named ma // For types that explicitly allow additional properties, we can either // unmarshal them into a map[string]any, or delay unmarshalling with // json.RawMessage. For now, use json.RawMessage as it defers the choice. - if def.Type == "object" && def.AdditionalProperties != nil && def.AdditionalProperties != false { + if def.Type == "object" && canHaveAdditionalProperties(def) { w.Write([]byte("json.RawMessage")) return nil } - switch typ := def.Type.(type) { - case string: - switch typ { + if def.Type == "" { + // E.g. union types. + fmt.Fprintf(w, "json.RawMessage") + } else { + switch def.Type { case "array": fmt.Fprintf(w, "[]") return writeType(w, nil, def.Items, named) @@ -281,8 +284,7 @@ func writeType(w io.Writer, config *typeConfig, def *jsonschema.Schema, named ma // TODO: use omitzero when available. needPointer := !required && (strings.HasPrefix(fieldDef.Ref, "#/definitions/") || - fieldDef.Type == "object" && - (fieldDef.AdditionalProperties == nil || fieldDef.AdditionalProperties == false)) + fieldDef.Type == "object" && !canHaveAdditionalProperties(fieldDef)) if config != nil && config.Fields[export] != nil { r := config.Fields[export] @@ -320,10 +322,6 @@ func writeType(w io.Writer, config *typeConfig, def *jsonschema.Schema, named ma default: fmt.Fprintf(w, "any") } - - default: - // E.g. union types. - fmt.Fprintf(w, "json.RawMessage") } return nil } @@ -363,6 +361,16 @@ func toComment(description string) string { return strings.TrimRight(buf.String(), "\n") } +// The MCP spec improperly uses the absence of the additionalProperties keyword to +// mean that additional properties are not allowed. In fact, it means just the opposite +// (https://json-schema.org/draft-07/draft-handrews-json-schema-validation-01#rfc.section.6.5.6). +// If the MCP spec wants to allow additional properties, it will write "true" or +// an object explicitly. +func canHaveAdditionalProperties(s *jsonschema.Schema) bool { + ap := s.AdditionalProperties + return ap != nil && !reflect.DeepEqual(ap, &jsonschema.Schema{Not: &jsonschema.Schema{}}) +} + // exportName returns an exported name for a Go symbol, based on the given name // in the JSON schema, removing leading underscores and capitalizing. func exportName(s string) string { diff --git a/mcp/mcp_test.go b/mcp/mcp_test.go index 654be79..e178e3c 100644 --- a/mcp/mcp_test.go +++ b/mcp/mcp_test.go @@ -92,14 +92,14 @@ func TestEndToEnd(t *testing.T) { Properties: map[string]*jsonschema.Schema{ "Name": {Type: "string"}, }, - AdditionalProperties: false, + AdditionalProperties: falseSchema, }, }, { Name: "fail", Description: "just fail", InputSchema: &jsonschema.Schema{ Type: "object", - AdditionalProperties: false, + AdditionalProperties: falseSchema, }, }} if diff := cmp.Diff(wantTools, gotTools); diff != "" { @@ -221,7 +221,6 @@ func TestBatching(t *testing.T) { } } } - } func TestCancellation(t *testing.T) { @@ -253,3 +252,5 @@ func TestCancellation(t *testing.T) { t.Fatal("timeout waiting for cancellation") } } + +var falseSchema = &jsonschema.Schema{Not: &jsonschema.Schema{}} diff --git a/mcp/tool.go b/mcp/tool.go index 452fbfd..bd19539 100644 --- a/mcp/tool.go +++ b/mcp/tool.go @@ -30,7 +30,7 @@ type Tool struct { // a valid schema, as documented by [jsonschema.ForType]; otherwise, MakeTool // panics. func MakeTool[TReq any](name, description string, handler func(context.Context, *ClientConnection, TReq) ([]Content, error)) *Tool { - schema, err := jsonschema.ForType[TReq]() + schema, err := jsonschema.For[TReq]() if err != nil { panic(err) }