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) }