| // Copyright 2025 The Go Authors. All rights reserved. |
| // Use of this source code is governed by a BSD-style |
| // license that can be found in the LICENSE file. |
| |
| package mcp |
| |
| import ( |
| "bytes" |
| "context" |
| "encoding/json" |
| "fmt" |
| "slices" |
| |
| "golang.org/x/tools/internal/mcp/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 handles a call to tools/call with typed arguments and results. |
| type ToolHandlerFor[In, Out any] func(context.Context, *ServerSession, *CallToolParamsFor[In]) (*CallToolResultFor[Out], 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 NewServerTool or Server.AddToolsErr. |
| rawHandler rawToolHandler |
| // Resolved tool schemas. Set in Server.AddToolsErr. |
| // TODO(rfindley): re-enable schema validation. For now, it is causing breakage in Google. |
| // inputResolved, outputResolved *jsonschema.Resolved |
| } |
| |
| // NewServerTool 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 In. |
| // |
| // 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 NewServerTool[In, Out any](name, description string, handler ToolHandlerFor[In, Out], opts ...ToolOption) *ServerTool { |
| st, err := newServerToolErr[In, Out](name, description, handler, opts...) |
| if err != nil { |
| panic(fmt.Errorf("NewServerTool(%q): %w", name, err)) |
| } |
| return st |
| } |
| |
| func newServerToolErr[In, Out any](name, description string, handler ToolHandlerFor[In, Out], opts ...ToolOption) (*ServerTool, error) { |
| // TODO: check that In is a struct. |
| ischema, err := jsonschema.For[In]() |
| 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 In |
| if rparams.Arguments != nil { |
| // TODO(rfindley): re-enable schema validation. See note in [ServerTool]. |
| if err := unmarshalSchema(rparams.Arguments, nil, &args); err != nil { |
| return nil, err |
| } |
| } |
| // TODO(jba): future-proof this copy. |
| params := &CallToolParamsFor[In]{ |
| 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 NewServerTool. |
| // 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 rparams.Arguments != nil { |
| // TODO(rfindley): re-enable schema validation. See note in [ServerTool]. |
| if err := unmarshalSchema(rparams.Arguments, nil, &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: test with nil args. |
| if resolved != nil { |
| if err := resolved.ApplyDefaults(v); err != nil { |
| return fmt.Errorf("applying defaults from \n\t%s\nto\n\t%s:\n%w", schemaJSON(resolved.Schema()), data, err) |
| } |
| 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) |
| } |