Skip to content

Commit 1b3cfad

Browse files
committed
jsonschema: support "jsonschema" struct tags
Struct fields may have the "jsonschema" struct tag, which is used as the description of the property. Fixes #47.
1 parent aebd244 commit 1b3cfad

File tree

4 files changed

+141
-8
lines changed

4 files changed

+141
-8
lines changed

jsonschema/infer.go

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ package jsonschema
99
import (
1010
"fmt"
1111
"reflect"
12+
"regexp"
1213

1314
"github.com/modelcontextprotocol/go-sdk/internal/util"
1415
)
@@ -37,6 +38,11 @@ import (
3738
// - unsafe pointers
3839
//
3940
// The types must not have cycles.
41+
//
42+
// For recognizes struct field tags named "jsonschema".
43+
// A jsonschema tag on a field is used as the description for the corresponding property.
44+
// For future compatibility, descriptions must not start with "WORD=", where WORD is a
45+
// sequence of non-whitespace characters.
4046
func For[T any]() (*Schema, error) {
4147
// TODO: consider skipping incompatible fields, instead of failing.
4248
s, err := forType(reflect.TypeFor[T]())
@@ -114,7 +120,20 @@ func forType(t reflect.Type) (*Schema, error) {
114120
if s.Properties == nil {
115121
s.Properties = make(map[string]*Schema)
116122
}
117-
s.Properties[info.Name], err = forType(field.Type)
123+
fs, err := forType(field.Type)
124+
if err != nil {
125+
return nil, err
126+
}
127+
if tag, ok := field.Tag.Lookup("jsonschema"); ok {
128+
if tag == "" {
129+
return nil, fmt.Errorf("empty jsonschema tag on struct field %s.%s", t, field.Name)
130+
}
131+
if disallowedPrefixRegexp.MatchString(tag) {
132+
return nil, fmt.Errorf("tag must not begin with 'WORD=': %q", tag)
133+
}
134+
fs.Description = tag
135+
}
136+
s.Properties[info.Name] = fs
118137
if err != nil {
119138
return nil, err
120139
}
@@ -132,3 +151,6 @@ func forType(t reflect.Type) (*Schema, error) {
132151
}
133152
return s, nil
134153
}
154+
155+
// Disallow jsonschema tag values beginning "WORD=", for future expansion.
156+
var disallowedPrefixRegexp = regexp.MustCompile("^[^ \t\n]*=")

jsonschema/infer_test.go

Lines changed: 45 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,11 @@ func forType[T any]() *jsonschema.Schema {
2222

2323
func TestForType(t *testing.T) {
2424
type schema = jsonschema.Schema
25+
26+
type S struct {
27+
B int `jsonschema:"bdesc"`
28+
}
29+
2530
tests := []struct {
2631
name string
2732
got *jsonschema.Schema
@@ -44,9 +49,9 @@ func TestForType(t *testing.T) {
4449
{
4550
"struct",
4651
forType[struct {
47-
F int `json:"f"`
52+
F int `json:"f" jsonschema:"fdesc"`
4853
G []float64
49-
P *bool
54+
P *bool `jsonschema:"pdesc"`
5055
Skip string `json:"-"`
5156
NoSkip string `json:",omitempty"`
5257
unexported float64
@@ -55,13 +60,13 @@ func TestForType(t *testing.T) {
5560
&schema{
5661
Type: "object",
5762
Properties: map[string]*schema{
58-
"f": {Type: "integer"},
63+
"f": {Type: "integer", Description: "fdesc"},
5964
"G": {Type: "array", Items: &schema{Type: "number"}},
60-
"P": {Types: []string{"null", "boolean"}},
65+
"P": {Types: []string{"null", "boolean"}, Description: "pdesc"},
6166
"NoSkip": {Type: "string"},
6267
},
6368
Required: []string{"f", "G", "P"},
64-
AdditionalProperties: &jsonschema.Schema{Not: &jsonschema.Schema{}},
69+
AdditionalProperties: falseSchema(),
6570
},
6671
},
6772
{
@@ -74,7 +79,37 @@ func TestForType(t *testing.T) {
7479
"Y": {Type: "integer"},
7580
},
7681
Required: []string{"X", "Y"},
77-
AdditionalProperties: &jsonschema.Schema{Not: &jsonschema.Schema{}},
82+
AdditionalProperties: falseSchema(),
83+
},
84+
},
85+
{
86+
"nested and embedded",
87+
forType[struct {
88+
A S
89+
S
90+
}](),
91+
&schema{
92+
Type: "object",
93+
Properties: map[string]*schema{
94+
"A": {
95+
Type: "object",
96+
Properties: map[string]*schema{
97+
"B": {Type: "integer", Description: "bdesc"},
98+
},
99+
Required: []string{"B"},
100+
AdditionalProperties: falseSchema(),
101+
},
102+
"S": {
103+
Type: "object",
104+
Properties: map[string]*schema{
105+
"B": {Type: "integer", Description: "bdesc"},
106+
},
107+
Required: []string{"B"},
108+
AdditionalProperties: falseSchema(),
109+
},
110+
},
111+
Required: []string{"A", "S"},
112+
AdditionalProperties: falseSchema(),
78113
},
79114
},
80115
}
@@ -91,3 +126,7 @@ func TestForType(t *testing.T) {
91126
})
92127
}
93128
}
129+
130+
func falseSchema() *jsonschema.Schema {
131+
return &jsonschema.Schema{Not: &jsonschema.Schema{}}
132+
}

mcp/tool.go

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
"fmt"
1212
"reflect"
1313

14+
"github.com/modelcontextprotocol/go-sdk/internal/util"
1415
"github.com/modelcontextprotocol/go-sdk/jsonschema"
1516
)
1617

@@ -89,7 +90,7 @@ func newServerTool[In, Out any](t *Tool, h ToolHandlerFor[In, Out]) (*serverTool
8990
func setSchema[T any](sfield **jsonschema.Schema, rfield **jsonschema.Resolved) error {
9091
var err error
9192
if *sfield == nil {
92-
*sfield, err = jsonschema.For[T]()
93+
*sfield, err = SchemaFor[T]()
9394
}
9495
if err != nil {
9596
return err
@@ -125,6 +126,31 @@ func unmarshalSchema(data json.RawMessage, resolved *jsonschema.Resolved, v any)
125126
return nil
126127
}
127128

129+
// SchemaFor returns a JSON Schema for type T.
130+
// It is like [jsonschema.For], but also uses "mcp" struct field tags
131+
// for property descriptions.
132+
//
133+
// For example, the call
134+
//
135+
// SchemaFor[struct{ B int `mcp:"desc"` }]()
136+
//
137+
// returns a schema with this value for "properties":
138+
//
139+
// {"B": {"type": "integer", "description": "desc"}}
140+
func SchemaFor[T any]() (*jsonschema.Schema, error) {
141+
// Infer the schema based on "json" tags alone.
142+
s, err := jsonschema.For[T]()
143+
if err != nil {
144+
return nil, err
145+
}
146+
147+
// Add descriptions from "mcp" tags.
148+
if err := addDescriptions(reflect.TypeFor[T](), s); err != nil {
149+
return nil, err
150+
}
151+
return s, nil
152+
}
153+
128154
// schemaJSON returns the JSON value for s as a string, or a string indicating an error.
129155
func schemaJSON(s *jsonschema.Schema) string {
130156
m, err := json.Marshal(s)

mcp/tool_test.go

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,3 +132,49 @@ func TestUnmarshalSchema(t *testing.T) {
132132

133133
}
134134
}
135+
136+
func TestSchemaFor(t *testing.T) {
137+
type S1 struct {
138+
G int `mcp:"gdesc"`
139+
}
140+
type S2 struct {
141+
A int
142+
B int `json:"b"`
143+
C int `mcp:"cdesc"`
144+
D int `json:"d" mcp:"ddesc"`
145+
E int `json:"-"`
146+
F S1 `json:"f"`
147+
S1
148+
}
149+
150+
got, err := SchemaFor[S2]()
151+
if err != nil {
152+
t.Fatal(err)
153+
}
154+
i := "integer"
155+
s1 := &jsonschema.Schema{
156+
Type: "object",
157+
Required: []string{"G"},
158+
AdditionalProperties: falseSchema(),
159+
Properties: map[string]*jsonschema.Schema{
160+
"G": {Type: i, Description: "gdesc"},
161+
},
162+
}
163+
want := &jsonschema.Schema{
164+
Type: "object",
165+
Properties: map[string]*jsonschema.Schema{
166+
"A": {Type: i},
167+
"b": {Type: i},
168+
"C": {Type: i, Description: "cdesc"},
169+
"d": {Type: i, Description: "ddesc"},
170+
"f": s1,
171+
"S1": s1,
172+
},
173+
Required: []string{"A", "b", "C", "d", "f", "S1"},
174+
AdditionalProperties: falseSchema(),
175+
}
176+
if diff := cmp.Diff(want, got, cmp.AllowUnexported(jsonschema.Schema{})); diff != "" {
177+
t.Errorf("mismatch (-want, +got):\n%s", diff)
178+
t.Log(schemaJSON(got))
179+
}
180+
}

0 commit comments

Comments
 (0)