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
147 lines
4 KiB
Go
147 lines
4 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"
|
|
"errors"
|
|
"fmt"
|
|
"reflect"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/google/jsonschema-go/jsonschema"
|
|
"github.com/modelcontextprotocol/go-sdk/internal/jsonrpc2"
|
|
)
|
|
|
|
func TestApplySchema(t *testing.T) {
|
|
schema := &jsonschema.Schema{
|
|
Type: "object",
|
|
Properties: map[string]*jsonschema.Schema{
|
|
"x": {Type: "integer", Default: json.RawMessage("3")},
|
|
},
|
|
}
|
|
resolved, err := schema.Resolve(&jsonschema.ResolveOptions{ValidateDefaults: true})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
type S struct {
|
|
X int `json:"x"`
|
|
}
|
|
|
|
for _, tt := range []struct {
|
|
data string
|
|
v any
|
|
want any
|
|
}{
|
|
{`{"x": 1}`, new(S), &S{X: 1}},
|
|
{`{}`, new(S), &S{X: 3}}, // default applied
|
|
{`{"x": 0}`, new(S), &S{X: 0}},
|
|
{`{"x": 1}`, new(map[string]any), &map[string]any{"x": 1.0}},
|
|
{`{}`, new(map[string]any), &map[string]any{"x": 3.0}}, // default applied
|
|
{`{"x": 0}`, new(map[string]any), &map[string]any{"x": 0.0}},
|
|
} {
|
|
raw := json.RawMessage(tt.data)
|
|
raw, err = applySchema(raw, resolved)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := json.Unmarshal(raw, &tt.v); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !reflect.DeepEqual(tt.v, tt.want) {
|
|
t.Errorf("got %#v, want %#v", tt.v, tt.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestToolErrorHandling(t *testing.T) {
|
|
// Construct server and add both tools at the top level
|
|
server := NewServer(testImpl, nil)
|
|
|
|
// Create a tool that returns a structured error
|
|
structuredErrorHandler := func(ctx context.Context, req *CallToolRequest, args map[string]any) (*CallToolResult, any, error) {
|
|
return nil, nil, &jsonrpc2.WireError{
|
|
Code: CodeInvalidParams,
|
|
Message: "internal server error",
|
|
}
|
|
}
|
|
|
|
// Create a tool that returns a regular error
|
|
regularErrorHandler := func(ctx context.Context, req *CallToolRequest, args map[string]any) (*CallToolResult, any, error) {
|
|
return nil, nil, fmt.Errorf("tool execution failed")
|
|
}
|
|
|
|
AddTool(server, &Tool{Name: "error_tool", Description: "returns structured error"}, structuredErrorHandler)
|
|
AddTool(server, &Tool{Name: "regular_error_tool", Description: "returns regular error"}, regularErrorHandler)
|
|
|
|
// Connect server and client once
|
|
ct, st := NewInMemoryTransports()
|
|
_, err := server.Connect(context.Background(), st, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
client := NewClient(testImpl, nil)
|
|
cs, err := client.Connect(context.Background(), ct, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer cs.Close()
|
|
|
|
// Test that structured JSON-RPC errors are returned directly
|
|
t.Run("structured_error", func(t *testing.T) {
|
|
// Call the tool
|
|
_, err = cs.CallTool(context.Background(), &CallToolParams{
|
|
Name: "error_tool",
|
|
Arguments: map[string]any{},
|
|
})
|
|
|
|
// Should get the structured error directly
|
|
if err == nil {
|
|
t.Fatal("expected error, got nil")
|
|
}
|
|
|
|
var wireErr *jsonrpc2.WireError
|
|
if !errors.As(err, &wireErr) {
|
|
t.Fatalf("expected WireError, got %[1]T: %[1]v", err)
|
|
}
|
|
|
|
if wireErr.Code != CodeInvalidParams {
|
|
t.Errorf("expected error code %d, got %d", CodeInvalidParams, wireErr.Code)
|
|
}
|
|
})
|
|
|
|
// Test that regular errors are embedded in tool results
|
|
t.Run("regular_error", func(t *testing.T) {
|
|
// Call the tool
|
|
result, err := cs.CallTool(context.Background(), &CallToolParams{
|
|
Name: "regular_error_tool",
|
|
Arguments: map[string]any{},
|
|
})
|
|
// Should not get an error at the protocol level
|
|
if err != nil {
|
|
t.Fatalf("unexpected protocol error: %v", err)
|
|
}
|
|
|
|
// Should get a result with IsError=true
|
|
if !result.IsError {
|
|
t.Error("expected IsError=true, got false")
|
|
}
|
|
|
|
// Should have error message in content
|
|
if len(result.Content) == 0 {
|
|
t.Error("expected error content, got empty")
|
|
}
|
|
|
|
if textContent, ok := result.Content[0].(*TextContent); !ok {
|
|
t.Error("expected TextContent")
|
|
} else if !strings.Contains(textContent.Text, "tool execution failed") {
|
|
t.Errorf("expected error message in content, got: %s", textContent.Text)
|
|
}
|
|
})
|
|
}
|