CallToolParams.Arguments is any. Clients can send whatever they want. Tool authors who create their own ToolHandler (bypassing NewTool) will receive the arguments as a map[string]any which has been validated against the input schema. Tool authors who call NewTool can choose the type that the arguments unmarshal into. There is no double-unmarshaling and no downcasting. There is no way for tool authors to avoid the unmarshal, and no way to avoid the validation unless the omit an input schema, which is probably bad for LLMs. If tool authors want this optimization, we can provide it later. Change-Id: Ieb201e56c7fb8a23bb1a93b27e946630ec9e79ad Reviewed-on: https://go-review.googlesource.com/c/tools/+/679595 TryBot-Bypass: Jonathan Amsterdam <jba@google.com> Auto-Submit: Jonathan Amsterdam <jba@google.com> Reviewed-by: Robert Findley <rfindley@google.com>
270 lines
8.3 KiB
Go
270 lines
8.3 KiB
Go
// 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 mcp
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"slices"
|
|
|
|
"github.com/modelcontextprotocol/go-sdk/jsonschema"
|
|
)
|
|
|
|
// A ToolHandler handles a call to tools/call.
|
|
// [CallToolParams.Arguments] will contain a map[string]any that has been validated
|
|
// against the input schema.
|
|
// Perhaps this should be an alias for ToolHandlerFor[map[string]any, map[string]any].
|
|
type ToolHandler func(context.Context, *ServerSession, *CallToolParamsFor[map[string]any]) (*CallToolResult, error)
|
|
|
|
// A ToolHandlerFor[Treq, Tres] handles a call to tools/call with typed arguments and results.
|
|
type ToolHandlerFor[Treq, Tres any] func(context.Context, *ServerSession, *CallToolParamsFor[Treq]) (*CallToolResultFor[Tres], error)
|
|
|
|
// A rawToolHandler is like a ToolHandler, but takes the arguments as as json.RawMessage.
|
|
type rawToolHandler = func(context.Context, *ServerSession, *CallToolParamsFor[json.RawMessage]) (*CallToolResult, error)
|
|
|
|
// A Tool is a tool definition that is bound to a tool handler.
|
|
type ServerTool struct {
|
|
Tool *Tool
|
|
Handler ToolHandler
|
|
// Set in NewTool or Server.AddToolsErr.
|
|
rawHandler rawToolHandler
|
|
// Resolved tool schemas. Set in Server.AddToolsErr.
|
|
inputResolved, outputResolved *jsonschema.Resolved
|
|
}
|
|
|
|
// NewTool is a helper to make a tool using reflection on the given type parameters.
|
|
// When the tool is called, CallToolParams.Arguments will be of type TReq.
|
|
//
|
|
// If provided, variadic [ToolOption] values may be used to customize the tool.
|
|
//
|
|
// The input schema for the tool is extracted from the request type for the
|
|
// handler, and used to unmmarshal and validate requests to the handler. This
|
|
// schema may be customized using the [Input] option.
|
|
func NewTool[TReq, TRes any](name, description string, handler ToolHandlerFor[TReq, TRes], opts ...ToolOption) *ServerTool {
|
|
st, err := newToolErr[TReq, TRes](name, description, handler, opts...)
|
|
if err != nil {
|
|
panic(fmt.Errorf("NewTool(%q): %w", name, err))
|
|
}
|
|
return st
|
|
}
|
|
|
|
func newToolErr[TReq, TRes any](name, description string, handler ToolHandlerFor[TReq, TRes], opts ...ToolOption) (*ServerTool, error) {
|
|
// TODO: check that TReq is a struct.
|
|
ischema, err := jsonschema.For[TReq]()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
// TODO: uncomment when output schemas drop.
|
|
// oschema, err := jsonschema.For[TRes]()
|
|
// if err != nil {
|
|
// return nil, err
|
|
// }
|
|
|
|
t := &ServerTool{
|
|
Tool: &Tool{
|
|
Name: name,
|
|
Description: description,
|
|
InputSchema: ischema,
|
|
// OutputSchema: oschema,
|
|
},
|
|
}
|
|
for _, opt := range opts {
|
|
opt.set(t)
|
|
}
|
|
|
|
t.rawHandler = func(ctx context.Context, ss *ServerSession, rparams *CallToolParamsFor[json.RawMessage]) (*CallToolResult, error) {
|
|
var args TReq
|
|
if err := unmarshalSchema(rparams.Arguments, t.inputResolved, &args); err != nil {
|
|
return nil, err
|
|
}
|
|
// TODO(jba): future-proof this copy.
|
|
params := &CallToolParamsFor[TReq]{
|
|
Meta: rparams.Meta,
|
|
Name: rparams.Name,
|
|
Arguments: args,
|
|
}
|
|
res, err := handler(ctx, ss, params)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var ctr CallToolResult
|
|
if res != nil {
|
|
// TODO(jba): future-proof this copy.
|
|
ctr.Meta = res.Meta
|
|
ctr.Content = res.Content
|
|
ctr.IsError = res.IsError
|
|
}
|
|
return &ctr, nil
|
|
}
|
|
return t, nil
|
|
}
|
|
|
|
// newRawHandler creates a rawToolHandler for tools not created through NewTool.
|
|
// It unmarshals the arguments into a map[string]any and validates them against the
|
|
// schema, then calls the ServerTool's handler.
|
|
func newRawHandler(st *ServerTool) rawToolHandler {
|
|
if st.Handler == nil {
|
|
panic("st.Handler is nil")
|
|
}
|
|
return func(ctx context.Context, ss *ServerSession, rparams *CallToolParamsFor[json.RawMessage]) (*CallToolResult, error) {
|
|
// Unmarshal the args into what should be a map.
|
|
var args map[string]any
|
|
if err := unmarshalSchema(rparams.Arguments, st.inputResolved, &args); err != nil {
|
|
return nil, err
|
|
}
|
|
// TODO: generate copy
|
|
params := &CallToolParamsFor[map[string]any]{
|
|
Meta: rparams.Meta,
|
|
Name: rparams.Name,
|
|
Arguments: args,
|
|
}
|
|
res, err := st.Handler(ctx, ss, params)
|
|
// TODO(rfindley): investigate why server errors are embedded in this strange way,
|
|
// rather than returned as jsonrpc2 server errors.
|
|
if err != nil {
|
|
return &CallToolResult{
|
|
Content: []*Content{NewTextContent(err.Error())},
|
|
IsError: true,
|
|
}, nil
|
|
}
|
|
return res, nil
|
|
}
|
|
}
|
|
|
|
// unmarshalSchema unmarshals data into v and validates the result according to
|
|
// the given resolved schema.
|
|
func unmarshalSchema(data json.RawMessage, resolved *jsonschema.Resolved, v any) error {
|
|
// TODO: use reflection to create the struct type to unmarshal into.
|
|
// Separate validation from assignment.
|
|
|
|
// Disallow unknown fields.
|
|
// Otherwise, if the tool was built with a struct, the client could send extra
|
|
// fields and json.Unmarshal would ignore them, so the schema would never get
|
|
// a chance to declare the extra args invalid.
|
|
dec := json.NewDecoder(bytes.NewReader(data))
|
|
dec.DisallowUnknownFields()
|
|
if err := dec.Decode(v); err != nil {
|
|
return fmt.Errorf("unmarshaling: %w", err)
|
|
}
|
|
// TODO(jba): apply defaults.
|
|
// TODO: test with nil args.
|
|
if resolved != nil {
|
|
if err := resolved.Validate(v); err != nil {
|
|
return fmt.Errorf("validating\n\t%s\nagainst\n\t %s:\n %w", data, schemaJSON(resolved.Schema()), err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// A ToolOption configures the behavior of a Tool.
|
|
type ToolOption interface {
|
|
set(*ServerTool)
|
|
}
|
|
|
|
type toolSetter func(*ServerTool)
|
|
|
|
func (s toolSetter) set(t *ServerTool) { s(t) }
|
|
|
|
// Input applies the provided [SchemaOption] configuration to the tool's input
|
|
// schema.
|
|
func Input(opts ...SchemaOption) ToolOption {
|
|
return toolSetter(func(t *ServerTool) {
|
|
for _, opt := range opts {
|
|
opt.set(t.Tool.InputSchema)
|
|
}
|
|
})
|
|
}
|
|
|
|
// A SchemaOption configures a jsonschema.Schema.
|
|
type SchemaOption interface {
|
|
set(s *jsonschema.Schema)
|
|
}
|
|
|
|
type schemaSetter func(*jsonschema.Schema)
|
|
|
|
func (s schemaSetter) set(schema *jsonschema.Schema) { s(schema) }
|
|
|
|
// Property configures the schema for the property of the given name.
|
|
// If there is no such property in the schema, it is created.
|
|
func Property(name string, opts ...SchemaOption) SchemaOption {
|
|
return schemaSetter(func(schema *jsonschema.Schema) {
|
|
propSchema, ok := schema.Properties[name]
|
|
if !ok {
|
|
propSchema = new(jsonschema.Schema)
|
|
schema.Properties[name] = propSchema
|
|
}
|
|
// Apply the options, with special handling for Required, as it needs to be
|
|
// set on the parent schema.
|
|
for _, opt := range opts {
|
|
if req, ok := opt.(required); ok {
|
|
if req {
|
|
if !slices.Contains(schema.Required, name) {
|
|
schema.Required = append(schema.Required, name)
|
|
}
|
|
} else {
|
|
schema.Required = slices.DeleteFunc(schema.Required, func(s string) bool {
|
|
return s == name
|
|
})
|
|
}
|
|
} else {
|
|
opt.set(propSchema)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
// Required sets whether the associated property is required. It is only valid
|
|
// when used in a [Property] option: using Required outside of Property panics.
|
|
func Required(v bool) SchemaOption {
|
|
return required(v)
|
|
}
|
|
|
|
// required must be a distinguished type as it needs special handling to mutate
|
|
// the parent schema, and to mutate prompt arguments.
|
|
type required bool
|
|
|
|
func (required) set(s *jsonschema.Schema) {
|
|
panic("use of required outside of Property")
|
|
}
|
|
|
|
// Enum sets the provided values as the "enum" value of the schema.
|
|
func Enum(values ...any) SchemaOption {
|
|
return schemaSetter(func(s *jsonschema.Schema) {
|
|
s.Enum = values
|
|
})
|
|
}
|
|
|
|
// Description sets the provided schema description.
|
|
func Description(desc string) SchemaOption {
|
|
return description(desc)
|
|
}
|
|
|
|
// description must be a distinguished type so that it can be handled by prompt
|
|
// options.
|
|
type description string
|
|
|
|
func (d description) set(s *jsonschema.Schema) {
|
|
s.Description = string(d)
|
|
}
|
|
|
|
// Schema overrides the inferred schema with a shallow copy of the given
|
|
// schema.
|
|
func Schema(schema *jsonschema.Schema) SchemaOption {
|
|
return schemaSetter(func(s *jsonschema.Schema) {
|
|
*s = *schema
|
|
})
|
|
}
|
|
|
|
// schemaJSON returns the JSON value for s as a string, or a string indicating an error.
|
|
func schemaJSON(s *jsonschema.Schema) string {
|
|
m, err := json.Marshal(s)
|
|
if err != nil {
|
|
return fmt.Sprintf("<!%s>", err)
|
|
}
|
|
return string(m)
|
|
}
|