Skip to content

Commit ab01700

Browse files
committed
internal/mcp: add tool and schema options
Implement variadic options for building tools and schemas, to allow easier customization of tool input schemas. Also, make fields required, unless they are marked as "omitempty". Change-Id: I7a29258fae3ec5e7ea4906ed30ed6750979de962 Reviewed-on: https://go-review.googlesource.com/c/tools/+/668955 Reviewed-by: Jonathan Amsterdam <[email protected]> LUCI-TryBot-Result: Go LUCI <[email protected]>
1 parent 37278be commit ab01700

File tree

7 files changed

+236
-14
lines changed

7 files changed

+236
-14
lines changed

internal/mcp/examples/hello/main.go

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import (
1717
var httpAddr = flag.String("http", "", "if set, use SSE HTTP at this address, instead of stdin/stdout")
1818

1919
type SayHiParams struct {
20-
Name string `json:"name" mcp:"the name to say hi to"`
20+
Name string `json:"name"`
2121
}
2222

2323
func SayHi(ctx context.Context, cc *mcp.ClientConnection, params *SayHiParams) ([]mcp.Content, error) {
@@ -30,7 +30,9 @@ func main() {
3030
flag.Parse()
3131

3232
server := mcp.NewServer("greeter", "v0.0.1", nil)
33-
server.AddTools(mcp.MakeTool("greet", "say hi", SayHi))
33+
server.AddTools(mcp.MakeTool("greet", "say hi", SayHi, mcp.Input(
34+
mcp.Property("name", mcp.Description("the name to say hi to")),
35+
)))
3436

3537
if *httpAddr != "" {
3638
handler := mcp.NewSSEHandler(func(*http.Request) *mcp.Server {

internal/mcp/internal/jsonschema/infer.go

Lines changed: 37 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ package jsonschema
99
import (
1010
"fmt"
1111
"reflect"
12+
"slices"
1213
"strings"
1314
)
1415

@@ -19,6 +20,21 @@ func For[T any]() (*Schema, error) {
1920
return ForType(reflect.TypeFor[T]())
2021
}
2122

23+
// ForType constructs a JSON schema object for the given type.
24+
// It translates Go types into compatible JSON schema types, as follows:
25+
// - strings have schema type "string"
26+
// - bools have schema type "boolean"
27+
// - signed and unsigned integer types have schema type "integer"
28+
// - floating point types have schema type "number"
29+
// - slices and arrays have schema type "array", and a corresponding schema
30+
// for items
31+
// - maps with string key have schema type "object", and corresponding
32+
// schema for additionalProperties
33+
// - structs have schema type "object", and disallow additionalProperties.
34+
// Their properties are derived from exported struct fields, using the
35+
// struct field json name. Fields that are marked "omitempty" are
36+
// considered optional; all other fields become required properties.
37+
//
2238
// It returns an error if t contains (possibly recursively) any of the following Go
2339
// types, as they are incompatible with the JSON schema spec.
2440
// - maps with key other than 'string'
@@ -75,6 +91,10 @@ func typeSchema(t reflect.Type, seen map[reflect.Type]*Schema) (*Schema, error)
7591
if err != nil {
7692
return nil, fmt.Errorf("computing element schema: %v", err)
7793
}
94+
if t.Kind() == reflect.Array {
95+
s.MinItems = Ptr(float64(t.Len()))
96+
s.MaxItems = Ptr(float64(t.Len()))
97+
}
7898

7999
case reflect.String:
80100
s.Type = "string"
@@ -86,8 +106,8 @@ func typeSchema(t reflect.Type, seen map[reflect.Type]*Schema) (*Schema, error)
86106

87107
for i := range t.NumField() {
88108
field := t.Field(i)
89-
name, ok := jsonName(field)
90-
if !ok {
109+
name, required, include := parseField(field)
110+
if !include {
91111
continue
92112
}
93113
if s.Properties == nil {
@@ -97,6 +117,9 @@ func typeSchema(t reflect.Type, seen map[reflect.Type]*Schema) (*Schema, error)
97117
if err != nil {
98118
return nil, err
99119
}
120+
if required {
121+
s.Required = append(s.Required, name)
122+
}
100123
}
101124

102125
default:
@@ -105,14 +128,21 @@ func typeSchema(t reflect.Type, seen map[reflect.Type]*Schema) (*Schema, error)
105128
return s, nil
106129
}
107130

108-
func jsonName(f reflect.StructField) (string, bool) {
131+
func parseField(f reflect.StructField) (name string, required, include bool) {
109132
if !f.IsExported() {
110-
return "", false
133+
return "", false, false
111134
}
135+
name = f.Name
136+
required = true
112137
if tag, ok := f.Tag.Lookup("json"); ok {
113-
if name, _, _ := strings.Cut(tag, ","); name != "" {
114-
return name, name != "-"
138+
props := strings.Split(tag, ",")
139+
if props[0] != "" {
140+
if props[0] == "-" {
141+
return "", false, false
142+
}
143+
name = props[0]
115144
}
145+
required = !slices.Contains(props[1:], "omitempty")
116146
}
117-
return f.Name, true
147+
return name, required, true
118148
}

internal/mcp/internal/jsonschema/infer_test.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ func TestForType(t *testing.T) {
5656
"P": {Type: "boolean"},
5757
"NoSkip": {Type: "string"},
5858
},
59+
Required: []string{"f", "G", "P"},
5960
AdditionalProperties: &jsonschema.Schema{Not: &jsonschema.Schema{}},
6061
}},
6162
}

internal/mcp/mcp.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,6 @@
6767
// - Support all client/server operations.
6868
// - Support streamable HTTP transport.
6969
// - Support multiple versions of the spec.
70-
// - Implement proper JSON schema support, with both client-side and
70+
// - Implement full JSON schema support, with both client-side and
7171
// server-side validation.
7272
package mcp

internal/mcp/mcp_test.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,8 @@ func TestEndToEnd(t *testing.T) {
8888
Name: "greet",
8989
Description: "say hi",
9090
InputSchema: &jsonschema.Schema{
91-
Type: "object",
91+
Type: "object",
92+
Required: []string{"Name"},
9293
Properties: map[string]*jsonschema.Schema{
9394
"Name": {Type: "string"},
9495
},

internal/mcp/tool.go

Lines changed: 102 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ package mcp
77
import (
88
"context"
99
"encoding/json"
10+
"slices"
1011

1112
"golang.org/x/tools/internal/mcp/internal/jsonschema"
1213
"golang.org/x/tools/internal/mcp/internal/protocol"
@@ -23,13 +24,16 @@ type Tool struct {
2324

2425
// MakeTool is a helper to make a tool using reflection on the given handler.
2526
//
27+
// If provided, variadic [ToolOption] values may be used to customize the tool.
28+
//
2629
// The input schema for the tool is extracted from the request type for the
27-
// handler, and used to unmmarshal and validate requests to the handler.
30+
// handler, and used to unmmarshal and validate requests to the handler. This
31+
// schema may be customized using the [Input] option.
2832
//
2933
// It is the caller's responsibility that the handler request type can produce
3034
// a valid schema, as documented by [jsonschema.ForType]; otherwise, MakeTool
3135
// panics.
32-
func MakeTool[TReq any](name, description string, handler func(context.Context, *ClientConnection, TReq) ([]Content, error)) *Tool {
36+
func MakeTool[TReq any](name, description string, handler func(context.Context, *ClientConnection, TReq) ([]Content, error), opts ...ToolOption) *Tool {
3337
schema, err := jsonschema.For[TReq]()
3438
if err != nil {
3539
panic(err)
@@ -51,14 +55,18 @@ func MakeTool[TReq any](name, description string, handler func(context.Context,
5155
}
5256
return res, nil
5357
}
54-
return &Tool{
58+
t := &Tool{
5559
Definition: protocol.Tool{
5660
Name: name,
5761
Description: description,
5862
InputSchema: schema,
5963
},
6064
Handler: wrapped,
6165
}
66+
for _, opt := range opts {
67+
opt.set(t)
68+
}
69+
return t
6270
}
6371

6472
// unmarshalSchema unmarshals data into v and validates the result according to
@@ -68,3 +76,94 @@ func unmarshalSchema(data json.RawMessage, _ *jsonschema.Schema, v any) error {
6876
// Separate validation from assignment.
6977
return json.Unmarshal(data, v)
7078
}
79+
80+
// A ToolOption configures the behavior of a Tool.
81+
type ToolOption interface {
82+
set(*Tool)
83+
}
84+
85+
type toolSetter func(*Tool)
86+
87+
func (s toolSetter) set(t *Tool) { s(t) }
88+
89+
// Input applies the provided [SchemaOption] configuration to the tool's input
90+
// schema.
91+
func Input(opts ...SchemaOption) ToolOption {
92+
return toolSetter(func(t *Tool) {
93+
for _, opt := range opts {
94+
opt.set(t.Definition.InputSchema)
95+
}
96+
})
97+
}
98+
99+
// A SchemaOption configures a jsonschema.Schema.
100+
type SchemaOption interface {
101+
set(s *jsonschema.Schema)
102+
}
103+
104+
type schemaSetter func(*jsonschema.Schema)
105+
106+
func (s schemaSetter) set(schema *jsonschema.Schema) { s(schema) }
107+
108+
// Property configures the schema for the property of the given name.
109+
// If there is no such property in the schema, it is created.
110+
func Property(name string, opts ...SchemaOption) SchemaOption {
111+
return schemaSetter(func(schema *jsonschema.Schema) {
112+
propSchema, ok := schema.Properties[name]
113+
if !ok {
114+
propSchema = new(jsonschema.Schema)
115+
schema.Properties[name] = propSchema
116+
}
117+
// Apply the options, with special handling for Required, as it needs to be
118+
// set on the parent schema.
119+
for _, opt := range opts {
120+
if req, ok := opt.(required); ok {
121+
if req {
122+
if !slices.Contains(schema.Required, name) {
123+
schema.Required = append(schema.Required, name)
124+
}
125+
} else {
126+
schema.Required = slices.DeleteFunc(schema.Required, func(s string) bool {
127+
return s == name
128+
})
129+
}
130+
} else {
131+
opt.set(propSchema)
132+
}
133+
}
134+
})
135+
}
136+
137+
// Required sets whether the associated property is required. It is only valid
138+
// when used in a [Property] option: using Required outside of Property panics.
139+
func Required(v bool) SchemaOption {
140+
return required(v)
141+
}
142+
143+
type required bool
144+
145+
func (required) set(s *jsonschema.Schema) {
146+
panic("use of required outside of Property")
147+
}
148+
149+
// Enum sets the provided values as the "enum" value of the schema.
150+
func Enum(values ...any) SchemaOption {
151+
return schemaSetter(func(s *jsonschema.Schema) {
152+
s.Enum = values
153+
})
154+
}
155+
156+
// Description sets the provided schema description.
157+
func Description(description string) SchemaOption {
158+
return schemaSetter(func(schema *jsonschema.Schema) {
159+
schema.Description = description
160+
})
161+
}
162+
163+
// Schema overrides the inferred schema with a shallow copy of the given
164+
// schema.
165+
func Schema(schema *jsonschema.Schema) SchemaOption {
166+
return schemaSetter(func(s *jsonschema.Schema) {
167+
*s = *schema
168+
})
169+
}

internal/mcp/tool_test.go

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
// Copyright 2025 The Go Authors. All rights reserved.
2+
// Use of this source code is governed by a BSD-style
3+
// license that can be found in the LICENSE file.
4+
5+
package mcp_test
6+
7+
import (
8+
"context"
9+
"testing"
10+
11+
"github.com/google/go-cmp/cmp"
12+
"golang.org/x/tools/internal/mcp"
13+
"golang.org/x/tools/internal/mcp/internal/jsonschema"
14+
)
15+
16+
// testHandler is used for type inference in TestMakeTool.
17+
func testHandler[T any](context.Context, *mcp.ClientConnection, T) ([]mcp.Content, error) {
18+
panic("not implemented")
19+
}
20+
21+
func TestMakeTool(t *testing.T) {
22+
tests := []struct {
23+
tool *mcp.Tool
24+
want *jsonschema.Schema
25+
}{
26+
{
27+
mcp.MakeTool("basic", "", testHandler[struct {
28+
Name string `json:"name"`
29+
}]),
30+
&jsonschema.Schema{
31+
Type: "object",
32+
Required: []string{"name"},
33+
Properties: map[string]*jsonschema.Schema{
34+
"name": {Type: "string"},
35+
},
36+
AdditionalProperties: &jsonschema.Schema{Not: new(jsonschema.Schema)},
37+
},
38+
},
39+
{
40+
mcp.MakeTool("enum", "", testHandler[struct{ Name string }], mcp.Input(
41+
mcp.Property("Name", mcp.Enum("x", "y", "z")),
42+
)),
43+
&jsonschema.Schema{
44+
Type: "object",
45+
Required: []string{"Name"},
46+
Properties: map[string]*jsonschema.Schema{
47+
"Name": {Type: "string", Enum: []any{"x", "y", "z"}},
48+
},
49+
AdditionalProperties: &jsonschema.Schema{Not: new(jsonschema.Schema)},
50+
},
51+
},
52+
{
53+
mcp.MakeTool("required", "", testHandler[struct {
54+
Name string `json:"name"`
55+
Language string `json:"language"`
56+
X int `json:"x,omitempty"`
57+
Y int `json:"y,omitempty"`
58+
}], mcp.Input(
59+
mcp.Property("x", mcp.Required(true)))),
60+
&jsonschema.Schema{
61+
Type: "object",
62+
Required: []string{"name", "language", "x"},
63+
Properties: map[string]*jsonschema.Schema{
64+
"language": {Type: "string"},
65+
"name": {Type: "string"},
66+
"x": {Type: "integer"},
67+
"y": {Type: "integer"},
68+
},
69+
AdditionalProperties: &jsonschema.Schema{Not: new(jsonschema.Schema)},
70+
},
71+
},
72+
{
73+
mcp.MakeTool("set_schema", "", testHandler[struct {
74+
X int `json:"x,omitempty"`
75+
Y int `json:"y,omitempty"`
76+
}], mcp.Input(
77+
mcp.Schema(&jsonschema.Schema{Type: "object"})),
78+
),
79+
&jsonschema.Schema{
80+
Type: "object",
81+
},
82+
},
83+
}
84+
for _, test := range tests {
85+
if diff := cmp.Diff(test.want, test.tool.Definition.InputSchema); diff != "" {
86+
t.Errorf("MakeTool(%v) mismatch (-want +got):\n%s", test.tool.Definition.Name, diff)
87+
}
88+
}
89+
}

0 commit comments

Comments
 (0)