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 <golang-scoped@luci-project-accounts.iam.gserviceaccount.com> Reviewed-by: Alan Donovan <adonovan@google.com>
This commit is contained in:
parent
14b18b2645
commit
3434e57007
9 changed files with 606 additions and 169 deletions
118
internal/jsonschema/infer.go
Normal file
118
internal/jsonschema/infer.go
Normal file
|
|
@ -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
|
||||
}
|
||||
70
internal/jsonschema/infer_test.go
Normal file
70
internal/jsonschema/infer_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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 "<anonymous schema>"
|
||||
}
|
||||
|
||||
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("<jsonschema.Schema:%v>", 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 }
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
162
internal/jsonschema/util.go
Normal file
162
internal/jsonschema/util.go
Normal file
|
|
@ -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
|
||||
}
|
||||
39
internal/jsonschema/util_test.go
Normal file
39
internal/jsonschema/util_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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{}}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue