Skip to content

Commit b4e5cec

Browse files
committed
Add reflection conversion to get strongly typed tool handlers
1 parent c53e4a3 commit b4e5cec

File tree

5 files changed

+369
-13
lines changed

5 files changed

+369
-13
lines changed

go.mod

+7
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,16 @@ go 1.24.0
55
require (
66
github.com/go-openapi/strfmt v0.23.0
77
github.com/grafana/grafana-openapi-client-go v0.0.0-20250108132429-8d7e1f158f65
8+
github.com/invopop/jsonschema v0.13.0
89
github.com/mark3labs/mcp-go v0.8.5
10+
github.com/stretchr/testify v1.10.0
911
)
1012

1113
require (
1214
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect
15+
github.com/bahlo/generic-list-go v0.2.0 // indirect
16+
github.com/buger/jsonparser v1.1.1 // indirect
17+
github.com/davecgh/go-spew v1.1.1 // indirect
1318
github.com/go-logr/logr v1.4.1 // indirect
1419
github.com/go-logr/stdr v1.2.2 // indirect
1520
github.com/go-openapi/analysis v0.23.0 // indirect
@@ -27,6 +32,8 @@ require (
2732
github.com/mitchellh/mapstructure v1.5.0 // indirect
2833
github.com/oklog/ulid v1.3.1 // indirect
2934
github.com/opentracing/opentracing-go v1.2.0 // indirect
35+
github.com/pmezard/go-difflib v1.0.0 // indirect
36+
github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect
3037
go.mongodb.org/mongo-driver v1.14.0 // indirect
3138
go.opentelemetry.io/otel v1.24.0 // indirect
3239
go.opentelemetry.io/otel/metric v1.24.0 // indirect

go.sum

+10-2
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so=
22
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw=
3+
github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
4+
github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
5+
github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs=
6+
github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
37
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
48
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
59
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -36,6 +40,8 @@ github.com/grafana/grafana-openapi-client-go v0.0.0-20250108132429-8d7e1f158f65
3640
github.com/grafana/grafana-openapi-client-go v0.0.0-20250108132429-8d7e1f158f65/go.mod h1:hiZnMmXc9KXNUlvkV2BKFsiWuIFF/fF4wGgYWEjBitI=
3741
github.com/grafana/mcp-go v0.8.6-0.20250226194234-5e1fa6f6f8af h1:SwCTIu0dpOapmOc+3HQtC5VM7CUimW0r83u+5fCgQTU=
3842
github.com/grafana/mcp-go v0.8.6-0.20250226194234-5e1fa6f6f8af/go.mod h1:cjMlBU0cv/cj9kjlgmRhoJ5JREdS7YX83xeIG9Ko/jE=
43+
github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E=
44+
github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0=
3945
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
4046
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
4147
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
@@ -56,8 +62,10 @@ github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDN
5662
github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA=
5763
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
5864
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
59-
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
60-
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
65+
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
66+
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
67+
github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc=
68+
github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw=
6169
go.mongodb.org/mongo-driver v1.14.0 h1:P98w8egYRjYe3XDjxhYJagTokP/H6HzlsnojRgZRd80=
6270
go.mongodb.org/mongo-driver v1.14.0/go.mod h1:Vzb0Mk/pa7e6cWw85R4F/endUC3u0U9jGcNU603k65c=
6371
go.opentelemetry.io/otel v1.24.0 h1:0LAOdjNmQeSTzGBzduGe/rU4tZhMwL5rWgtp9Ku5Jfo=

tools.go

+140
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
package mcpgrafana
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"fmt"
7+
"reflect"
8+
9+
"github.com/invopop/jsonschema"
10+
"github.com/mark3labs/mcp-go/mcp"
11+
"github.com/mark3labs/mcp-go/server"
12+
)
13+
14+
func MustTool(name, description string, toolHandler any) (mcp.Tool, server.ToolHandlerFunc) {
15+
tool, handler, err := ConvertTool(name, description, toolHandler)
16+
if err != nil {
17+
panic(err)
18+
}
19+
return tool, handler
20+
}
21+
22+
func ConvertTool(name, description string, toolHandler any) (mcp.Tool, server.ToolHandlerFunc, error) {
23+
zero := mcp.Tool{}
24+
handlerValue := reflect.ValueOf(toolHandler)
25+
handlerType := handlerValue.Type()
26+
if handlerType.Kind() != reflect.Func {
27+
return zero, nil, fmt.Errorf("tool handler must be a function")
28+
}
29+
if handlerType.NumIn() != 2 {
30+
return zero, nil, fmt.Errorf("tool handler must have 2 arguments")
31+
}
32+
if handlerType.NumOut() != 2 {
33+
return zero, nil, fmt.Errorf("tool handler must return 2 values")
34+
}
35+
if handlerType.In(0) != reflect.TypeOf((*context.Context)(nil)).Elem() {
36+
return zero, nil, fmt.Errorf("tool handler first argument must be context.Context")
37+
}
38+
if handlerType.Out(0) != reflect.TypeOf(&mcp.CallToolResult{}) {
39+
return zero, nil, fmt.Errorf("tool handler first return value must be mcp.CallToolResult")
40+
}
41+
if handlerType.Out(1).Kind() != reflect.Interface {
42+
return zero, nil, fmt.Errorf("tool handler second return value must be error")
43+
}
44+
45+
argType := handlerType.In(1)
46+
if argType.Kind() != reflect.Struct {
47+
return zero, nil, fmt.Errorf("tool handler second argument must be a struct")
48+
}
49+
50+
handler := func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
51+
52+
s, err := json.Marshal(request.Params.Arguments)
53+
if err != nil {
54+
return nil, fmt.Errorf("marshal args: %w", err)
55+
}
56+
57+
unmarshaledArgs := reflect.New(argType).Interface()
58+
if err := json.Unmarshal([]byte(s), unmarshaledArgs); err != nil {
59+
return mcp.NewToolResultError(fmt.Sprintf("unmarshal args: %s", err)), nil
60+
}
61+
62+
// Need to dereference the unmarshaled arguments
63+
of := reflect.ValueOf(unmarshaledArgs)
64+
if of.Kind() != reflect.Ptr || !of.Elem().CanInterface() {
65+
return mcp.NewToolResultError("arguments must be a struct"), nil
66+
}
67+
68+
args := []reflect.Value{reflect.ValueOf(ctx), of.Elem()}
69+
70+
output := handlerValue.Call(args)
71+
if len(output) != 2 {
72+
return mcp.NewToolResultError("tool handler must return 2 values"), nil
73+
}
74+
if !output[0].CanInterface() {
75+
return mcp.NewToolResultError("tool handler first return value must be mcp.CallToolResult"), nil
76+
}
77+
var result *mcp.CallToolResult
78+
var ok bool
79+
if !output[0].IsNil() {
80+
result, ok = output[0].Interface().(*mcp.CallToolResult)
81+
if !ok {
82+
return mcp.NewToolResultError("tool handler first return value must be mcp.CallToolResult"), nil
83+
}
84+
}
85+
var handlerErr error
86+
if !output[1].IsNil() {
87+
handlerErr, ok = output[1].Interface().(error)
88+
if !ok {
89+
return mcp.NewToolResultError("tool handler second return value must be error"), nil
90+
}
91+
}
92+
return result, handlerErr
93+
}
94+
95+
jsonSchema := createJsonSchemaFromHandler(toolHandler)
96+
properties := make(map[string]any, jsonSchema.Properties.Len())
97+
for pair := jsonSchema.Properties.Oldest(); pair != nil; pair = pair.Next() {
98+
properties[pair.Key] = pair.Value
99+
}
100+
inputSchema := mcp.ToolInputSchema{
101+
Type: jsonSchema.Type,
102+
Properties: properties,
103+
Required: jsonSchema.Required,
104+
}
105+
106+
return mcp.Tool{
107+
Name: name,
108+
Description: description,
109+
InputSchema: inputSchema,
110+
}, handler, nil
111+
}
112+
113+
// Creates a full JSON schema from a user provided handler by introspecting the arguments
114+
func createJsonSchemaFromHandler(handler any) *jsonschema.Schema {
115+
handlerValue := reflect.ValueOf(handler)
116+
handlerType := handlerValue.Type()
117+
argumentType := handlerType.In(1)
118+
inputSchema := jsonSchemaReflector.ReflectFromType(argumentType)
119+
return inputSchema
120+
}
121+
122+
var (
123+
jsonSchemaReflector = jsonschema.Reflector{
124+
BaseSchemaID: "",
125+
Anonymous: true,
126+
AssignAnchor: false,
127+
AllowAdditionalProperties: true,
128+
RequiredFromJSONSchemaTags: true,
129+
DoNotReference: true,
130+
ExpandedStruct: true,
131+
FieldNameTag: "",
132+
IgnoredTypes: nil,
133+
Lookup: nil,
134+
Mapper: nil,
135+
Namer: nil,
136+
KeyNamer: nil,
137+
AdditionalFields: nil,
138+
CommentMap: nil,
139+
}
140+
)

tools/search.go

+8-11
Original file line numberDiff line numberDiff line change
@@ -12,15 +12,14 @@ import (
1212
)
1313

1414
type SearchDashboardsParams struct {
15+
Query string `json:"query" jsonschema:"description=The query to search for"`
1516
}
1617

17-
func SearchDashboardsHandler(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
18+
func searchDashboards(ctx context.Context, args SearchDashboardsParams) (*mcp.CallToolResult, error) {
1819
c := mcpgrafana.GrafanaClientFromContext(ctx)
1920
params := search.NewSearchParamsWithContext(ctx)
20-
if q, ok := request.Params.Arguments["query"]; ok {
21-
if q, ok := q.(string); ok {
22-
params.SetQuery(&q)
23-
}
21+
if args.Query != "" {
22+
params.SetQuery(&args.Query)
2423
}
2524
search, err := c.Search.Search(params)
2625
if err != nil {
@@ -33,10 +32,8 @@ func SearchDashboardsHandler(ctx context.Context, request mcp.CallToolRequest) (
3332
return mcp.NewToolResultText(string(b)), nil
3433
}
3534

36-
var SearchDashboardsTool = mcp.NewTool("search_dashboards",
37-
mcp.WithDescription("Search for dashboards"),
38-
mcp.WithString("query",
39-
mcp.Description("Query string"),
40-
mcp.Required(),
41-
),
35+
var SearchDashboardsTool, SearchDashboardsHandler = mcpgrafana.MustTool(
36+
"search_dashboards",
37+
"Search for dashboards",
38+
searchDashboards,
4239
)

0 commit comments

Comments
 (0)