jsonschema: add more schema fields

Add additional fields to Schema which are mentioned in the spec but
do not affect validation.

Change-Id: I89488261a00c207d01cd7fc59d782f43692fe528
Reviewed-on: https://go-review.googlesource.com/c/tools/+/674976
Reviewed-by: Alan Donovan <adonovan@google.com>
LUCI-TryBot-Result: Go LUCI <golang-scoped@luci-project-accounts.iam.gserviceaccount.com>
Auto-Submit: Jonathan Amsterdam <jba@google.com>
diff --git a/internal/mcp/jsonschema/resolve.go b/internal/mcp/jsonschema/resolve.go
index f82eec1..d28fba4 100644
--- a/internal/mcp/jsonschema/resolve.go
+++ b/internal/mcp/jsonschema/resolve.go
@@ -153,6 +153,14 @@
 	// TODO: validate the schema's properties,
 	// ideally by jsonschema-validating it against the meta-schema.
 
+	// Some properties are present so that Schemas can round-trip, but we do not
+	// validate them.
+	// Currently, it's just the $vocabulary property.
+	// As a special case, we can validate the 2020-12 meta-schema.
+	if s.Vocabulary != nil && s.Schema != draft202012 {
+		addf("cannot validate a schema with $vocabulary")
+	}
+
 	// Check and compile regexps.
 	if s.Pattern != "" {
 		re, err := regexp.Compile(s.Pattern)
diff --git a/internal/mcp/jsonschema/schema.go b/internal/mcp/jsonschema/schema.go
index 1ec4b0d..d6d5f76 100644
--- a/internal/mcp/jsonschema/schema.go
+++ b/internal/mcp/jsonschema/schema.go
@@ -58,6 +58,11 @@
 	// metadata
 	Title       string `json:"title,omitempty"`
 	Description string `json:"description,omitempty"`
+	Default     *any   `json:"default,omitempty"`
+	Deprecated  bool   `json:"deprecated,omitempty"`
+	ReadOnly    bool   `json:"readOnly,omitempty"`
+	WriteOnly   bool   `json:"writeOnly,omitempty"`
+	Examples    []any  `json:"examples,omitempty"`
 
 	// validation
 	// Use Type for a single type, or Types for multiple types; never both.
@@ -110,6 +115,15 @@
 	Else             *Schema            `json:"else,omitempty"`
 	DependentSchemas map[string]*Schema `json:"dependentSchemas,omitempty"`
 
+	// other
+	// https://json-schema.org/draft/2020-12/draft-bhutton-json-schema-validation-00#rfc.section.8
+	ContentEncoding  string  `json:"contentEncoding,omitempty"`
+	ContentMediaType string  `json:"contentMediaType,omitempty"`
+	ContentSchema    *Schema `json:"contentSchema,omitempty"`
+
+	// https://json-schema.org/draft/2020-12/draft-bhutton-json-schema-validation-00#rfc.section.7
+	Format string `json:"format,omitempty"`
+
 	// computed fields
 
 	// This schema's base schema.
@@ -237,6 +251,7 @@
 	ms := struct {
 		Type          json.RawMessage `json:"type,omitempty"`
 		Const         json.RawMessage `json:"const,omitempty"`
+		Default       json.RawMessage `json:"default,omitempty"`
 		MinLength     *integer        `json:"minLength,omitempty"`
 		MaxLength     *integer        `json:"maxLength,omitempty"`
 		MinItems      *integer        `json:"minItems,omitempty"`
@@ -269,14 +284,24 @@
 		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
+	unmarshalAnyPtr := func(p **any, raw json.RawMessage) error {
+		if len(raw) == 0 {
+			return nil
 		}
+		if bytes.Equal(raw, []byte("null")) {
+			*p = new(any)
+			return nil
+		}
+		return json.Unmarshal(raw, p)
+	}
+
+	// Setting Const or Default to a pointer to null will marshal properly, but won't
+	// unmarshal: the *any is set to nil, not a pointer to nil.
+	if err := unmarshalAnyPtr(&s.Const, ms.Const); err != nil {
+		return err
+	}
+	if err := unmarshalAnyPtr(&s.Default, ms.Default); err != nil {
+		return err
 	}
 
 	set := func(dst **int, src *integer) {
diff --git a/internal/mcp/jsonschema/schema_test.go b/internal/mcp/jsonschema/schema_test.go
index 4d042d5..8394bb5 100644
--- a/internal/mcp/jsonschema/schema_test.go
+++ b/internal/mcp/jsonschema/schema_test.go
@@ -24,6 +24,7 @@
 		{Const: Ptr(any(nil))},
 		{Const: Ptr(any([]int{}))},
 		{Const: Ptr(any(map[string]any{}))},
+		{Default: Ptr(any(nil))},
 	} {
 		data, err := json.Marshal(s)
 		if err != nil {
@@ -31,9 +32,7 @@
 		}
 		t.Logf("marshal: %s", data)
 		var got *Schema
-		if err := json.Unmarshal(data, &got); err != nil {
-			t.Fatal(err)
-		}
+		mustUnmarshal(t, data, &got)
 		if !Equal(got, s) {
 			t.Errorf("got %+v, want %+v", got, s)
 			if got.Const != nil && s.Const != nil {
@@ -68,9 +67,7 @@
 		{`{"unk":0}`, `{}`}, // unknown fields are dropped, unfortunately
 	} {
 		var s Schema
-		if err := json.Unmarshal([]byte(tt.in), &s); err != nil {
-			t.Fatal(err)
-		}
+		mustUnmarshal(t, []byte(tt.in), &s)
 		data, err := json.Marshal(s)
 		if err != nil {
 			t.Fatal(err)
@@ -126,3 +123,10 @@
 		t.Errorf("got %d, want %d", got, want)
 	}
 }
+
+func mustUnmarshal(t *testing.T, data []byte, ptr any) {
+	t.Helper()
+	if err := json.Unmarshal(data, ptr); err != nil {
+		t.Fatal(err)
+	}
+}
diff --git a/internal/mcp/jsonschema/testdata/draft2020-12/default.json b/internal/mcp/jsonschema/testdata/draft2020-12/default.json
new file mode 100644
index 0000000..ceb3ae2
--- /dev/null
+++ b/internal/mcp/jsonschema/testdata/draft2020-12/default.json
@@ -0,0 +1,82 @@
+[
+    {
+        "description": "invalid type for default",
+        "schema": {
+            "$schema": "https://json-schema.org/draft/2020-12/schema",
+            "properties": {
+                "foo": {
+                    "type": "integer",
+                    "default": []
+                }
+            }
+        },
+        "tests": [
+            {
+                "description": "valid when property is specified",
+                "data": {"foo": 13},
+                "valid": true
+            },
+            {
+                "description": "still valid when the invalid default is used",
+                "data": {},
+                "valid": true
+            }
+        ]
+    },
+    {
+        "description": "invalid string value for default",
+        "schema": {
+            "$schema": "https://json-schema.org/draft/2020-12/schema",
+            "properties": {
+                "bar": {
+                    "type": "string",
+                    "minLength": 4,
+                    "default": "bad"
+                }
+            }
+        },
+        "tests": [
+            {
+                "description": "valid when property is specified",
+                "data": {"bar": "good"},
+                "valid": true
+            },
+            {
+                "description": "still valid when the invalid default is used",
+                "data": {},
+                "valid": true
+            }
+        ]
+    },
+    {
+        "description": "the default keyword does not do anything if the property is missing",
+        "schema": {
+            "$schema": "https://json-schema.org/draft/2020-12/schema",
+            "type": "object",
+            "properties": {
+                "alpha": {
+                    "type": "number",
+                    "maximum": 3,
+                    "default": 5
+                }
+            }
+        },
+        "tests": [
+            {
+                "description": "an explicit property value is checked against maximum (passing)",
+                "data": { "alpha": 1 },
+                "valid": true
+            },
+            {
+                "description": "an explicit property value is checked against maximum (failing)",
+                "data": { "alpha": 5 },
+                "valid": false
+            },
+            {
+                "description": "missing properties are not filled in with the default",
+                "data": {},
+                "valid": true
+            }
+        ]
+    }
+]
diff --git a/internal/mcp/jsonschema/validate_test.go b/internal/mcp/jsonschema/validate_test.go
index 3d096df..d6be6f8 100644
--- a/internal/mcp/jsonschema/validate_test.go
+++ b/internal/mcp/jsonschema/validate_test.go
@@ -42,14 +42,12 @@
 	for _, file := range files {
 		base := filepath.Base(file)
 		t.Run(base, func(t *testing.T) {
-			f, err := os.Open(file)
+			data, err := os.ReadFile(file)
 			if err != nil {
 				t.Fatal(err)
 			}
-			defer f.Close()
-			dec := json.NewDecoder(f)
 			var groups []testGroup
-			if err := dec.Decode(&groups); err != nil {
+			if err := json.Unmarshal(data, &groups); err != nil {
 				t.Fatal(err)
 			}
 			for _, g := range groups {