Our validation logic was avoiding double-unmarshalling as much as possible, by parsing before validation and validating the Go type. This only works if the Go type has the same structure as its JSON representation, which may not be the case in the presence of types with custom MarshalJSON or UnmarshalJSON methods (such as time.Time). But even if the Go type doesn't use any custom marshalling, validation is broken, because we can't differentiate zero values from missing values. Bite the bullet and use double-unmarshalling for both input and output schemas. Coincidentally, this fixes three bugs: - We were accepting case-insensitive JSON keys, since we parsed first, even though they should have been rejected. A number of tests were wrong. - Defaults were overriding present-yet-zero values, as noted in an incorrect test case. - When "arguments" was missing, validation wasn't performed, no defaults were applied, and unmarshalling failed even if all properties were optional. First unmarshalling to map[string]any allows us to fix all these bugs. Unfortunately, it means a 3x increase in the number of reflection operations (we need to unmarshal, apply defaults and validate, re-marshal with the defaults, and then unmarshal into the Go type). However, this is not likely to be a significant overhead, and we can always optimize in the future. Update github.com/google/jsonschema-go to pick up necessary improvements supporting this change. Additionally, fix the error codes for invalid tool parameters, to be consistent with other SDKs (Invalid Params: -32602). Fixes #447 Fixes #449 Updates #450
103 lines
4.1 KiB
Go
103 lines
4.1 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 (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
|
|
"github.com/google/jsonschema-go/jsonschema"
|
|
)
|
|
|
|
// A ToolHandler handles a call to tools/call.
|
|
//
|
|
// This is a low-level API, for use with [Server.AddTool]. It does not do any
|
|
// pre- or post-processing of the request or result: the params contain raw
|
|
// arguments, no input validation is performed, and the result is returned to
|
|
// the user as-is, without any validation of the output.
|
|
//
|
|
// Most users will write a [ToolHandlerFor] and install it with the generic
|
|
// [AddTool] function.
|
|
//
|
|
// If ToolHandler returns an error, it is treated as a protocol error. By
|
|
// contrast, [ToolHandlerFor] automatically populates [CallToolResult.IsError]
|
|
// and [CallToolResult.Content] accordingly.
|
|
type ToolHandler func(context.Context, *CallToolRequest) (*CallToolResult, error)
|
|
|
|
// A ToolHandlerFor handles a call to tools/call with typed arguments and results.
|
|
//
|
|
// Use [AddTool] to add a ToolHandlerFor to a server.
|
|
//
|
|
// Unlike [ToolHandler], [ToolHandlerFor] provides significant functionality
|
|
// out of the box, and enforces that the tool conforms to the MCP spec:
|
|
// - The In type provides a default input schema for the tool, though it may
|
|
// be overridden in [AddTool].
|
|
// - The input value is automatically unmarshaled from req.Params.Arguments.
|
|
// - The input value is automatically validated against its input schema.
|
|
// Invalid input is rejected before getting to the handler.
|
|
// - If the Out type is not the empty interface [any], it provides the
|
|
// default output schema for the tool (which again may be overridden in
|
|
// [AddTool]).
|
|
// - The Out value is used to populate result.StructuredOutput.
|
|
// - If [CallToolResult.Content] is unset, it is populated with the JSON
|
|
// content of the output.
|
|
// - An error result is treated as a tool error, rather than a protocol
|
|
// error, and is therefore packed into CallToolResult.Content, with
|
|
// [IsError] set.
|
|
//
|
|
// For these reasons, most users can ignore the [CallToolRequest] argument and
|
|
// [CallToolResult] return values entirely. In fact, it is permissible to
|
|
// return a nil CallToolResult, if you only care about returning a output value
|
|
// or error. The effective result will be populated as described above.
|
|
type ToolHandlerFor[In, Out any] func(_ context.Context, request *CallToolRequest, input In) (result *CallToolResult, output Out, _ error)
|
|
|
|
// A serverTool is a tool definition that is bound to a tool handler.
|
|
type serverTool struct {
|
|
tool *Tool
|
|
handler ToolHandler
|
|
}
|
|
|
|
// applySchema validates whether data is valid JSON according to the provided
|
|
// schema, after applying schema defaults.
|
|
//
|
|
// Returns the JSON value augmented with defaults.
|
|
func applySchema(data json.RawMessage, resolved *jsonschema.Resolved) (json.RawMessage, error) {
|
|
// TODO: use reflection to create the struct type to unmarshal into.
|
|
// Separate validation from assignment.
|
|
|
|
// Use default JSON marshalling for validation.
|
|
//
|
|
// This avoids inconsistent representation due to custom marshallers, such as
|
|
// time.Time (issue #449).
|
|
//
|
|
// Additionally, unmarshalling into a map ensures that the resulting JSON is
|
|
// at least {}, even if data is empty. For example, arguments is technically
|
|
// an optional property of callToolParams, and we still want to apply the
|
|
// defaults in this case.
|
|
//
|
|
// TODO(rfindley): in which cases can resolved be nil?
|
|
if resolved != nil {
|
|
v := make(map[string]any)
|
|
if len(data) > 0 {
|
|
if err := json.Unmarshal(data, &v); err != nil {
|
|
return nil, fmt.Errorf("unmarshaling arguments: %w", err)
|
|
}
|
|
}
|
|
if err := resolved.ApplyDefaults(&v); err != nil {
|
|
return nil, fmt.Errorf("applying schema defaults:\n%w", err)
|
|
}
|
|
if err := resolved.Validate(&v); err != nil {
|
|
return nil, err
|
|
}
|
|
// We must re-marshal with the default values applied.
|
|
var err error
|
|
data, err = json.Marshal(v)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("marshalling with defaults: %v", err)
|
|
}
|
|
}
|
|
return data, nil
|
|
}
|