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
407 lines
12 KiB
Go
407 lines
12 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.
|
|
|
|
//go:build (go1.24 && goexperiment.synctest) || go1.25
|
|
|
|
package mcp
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"flag"
|
|
"fmt"
|
|
"io"
|
|
"io/fs"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"testing/synctest"
|
|
"time"
|
|
|
|
"github.com/google/go-cmp/cmp"
|
|
"github.com/google/go-cmp/cmp/cmpopts"
|
|
"github.com/google/jsonschema-go/jsonschema"
|
|
"github.com/modelcontextprotocol/go-sdk/internal/jsonrpc2"
|
|
"github.com/modelcontextprotocol/go-sdk/jsonrpc"
|
|
"golang.org/x/tools/txtar"
|
|
)
|
|
|
|
var update = flag.Bool("update", false, "if set, update conformance test data")
|
|
|
|
// A conformance test checks JSON-level conformance of a test server or client.
|
|
// This allows us to confirm that we can handle the input or output of other
|
|
// SDKs, even if they behave differently at the JSON level (for example, have
|
|
// different behavior with respect to optional fields).
|
|
//
|
|
// The client and server fields hold an encoded sequence of JSON-RPC messages.
|
|
//
|
|
// For server tests, the client messages are a sequence of messages to be sent
|
|
// from the (synthetic) client and the server messages are the expected
|
|
// messages to be received from the real server.
|
|
//
|
|
// For client tests, it's the other way around: server messages are synthetic,
|
|
// and client messages are expected from the real client.
|
|
//
|
|
// Conformance tests are loaded from txtar-encoded testdata files. Run the test
|
|
// with -update to have the test runner update the expected output, which may
|
|
// be client or server depending on the perspective of the test.
|
|
type conformanceTest struct {
|
|
name string // test name
|
|
path string // path to test file
|
|
archive *txtar.Archive // raw archive, for updating
|
|
tools, prompts, resources []string // named features to include
|
|
client []jsonrpc.Message // client messages
|
|
server []jsonrpc.Message // server messages
|
|
}
|
|
|
|
// TODO(rfindley): add client conformance tests.
|
|
|
|
func TestServerConformance(t *testing.T) {
|
|
var tests []*conformanceTest
|
|
dir := filepath.Join("testdata", "conformance", "server")
|
|
if err := filepath.WalkDir(dir, func(path string, _ fs.DirEntry, err error) error {
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if strings.HasSuffix(path, ".txtar") {
|
|
test, err := loadConformanceTest(dir, path)
|
|
if err != nil {
|
|
return fmt.Errorf("%s: %v", path, err)
|
|
}
|
|
tests = append(tests, test)
|
|
}
|
|
return nil
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
// We use synctest here because in general, there is no way to know when the
|
|
// server is done processing any notifications. As long as our server doesn't
|
|
// do background work, synctest provides an easy way for us to detect when the
|
|
// server is done processing.
|
|
//
|
|
// By comparison, gopls has a complicated framework based on progress
|
|
// reporting and careful accounting to detect when all 'expected' work
|
|
// on the server is complete.
|
|
runSyncTest(t, func(t *testing.T) { runServerTest(t, test) })
|
|
|
|
// TODO: in 1.25, use the following instead:
|
|
// synctest.Test(t, func(t *testing.T) {
|
|
// runServerTest(t, test)
|
|
// })
|
|
})
|
|
}
|
|
}
|
|
|
|
type structuredInput struct {
|
|
In string `jsonschema:"the input"`
|
|
}
|
|
|
|
type structuredOutput struct {
|
|
Out string `jsonschema:"the output"`
|
|
}
|
|
|
|
func structuredTool(ctx context.Context, req *CallToolRequest, args *structuredInput) (*CallToolResult, *structuredOutput, error) {
|
|
return nil, &structuredOutput{"Ack " + args.In}, nil
|
|
}
|
|
|
|
type tomorrowInput struct {
|
|
Now time.Time
|
|
}
|
|
|
|
type tomorrowOutput struct {
|
|
Tomorrow time.Time
|
|
}
|
|
|
|
func tomorrowTool(ctx context.Context, req *CallToolRequest, args tomorrowInput) (*CallToolResult, tomorrowOutput, error) {
|
|
return nil, tomorrowOutput{args.Now.Add(24 * time.Hour)}, nil
|
|
}
|
|
|
|
type incInput struct {
|
|
X int `json:"x,omitempty"`
|
|
}
|
|
|
|
type incOutput struct {
|
|
Y int `json:"y"`
|
|
}
|
|
|
|
func incTool(_ context.Context, _ *CallToolRequest, args incInput) (*CallToolResult, incOutput, error) {
|
|
return nil, incOutput{args.X + 1}, nil
|
|
}
|
|
|
|
// runServerTest runs the server conformance test.
|
|
// It must be executed in a synctest bubble.
|
|
func runServerTest(t *testing.T, test *conformanceTest) {
|
|
ctx := t.Context()
|
|
// Construct the server based on features listed in the test.
|
|
s := NewServer(&Implementation{Name: "testServer", Version: "v1.0.0"}, nil)
|
|
for _, tn := range test.tools {
|
|
switch tn {
|
|
case "greet":
|
|
AddTool(s, &Tool{
|
|
Name: "greet",
|
|
Description: "say hi",
|
|
}, sayHi)
|
|
case "structured":
|
|
AddTool(s, &Tool{Name: "structured"}, structuredTool)
|
|
case "tomorrow":
|
|
AddTool(s, &Tool{Name: "tomorrow"}, tomorrowTool)
|
|
case "inc":
|
|
inSchema, err := jsonschema.For[incInput](nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
inSchema.Properties["x"].Default = json.RawMessage(`6`)
|
|
AddTool(s, &Tool{Name: "inc", InputSchema: inSchema}, incTool)
|
|
default:
|
|
t.Fatalf("unknown tool %q", tn)
|
|
}
|
|
}
|
|
for _, pn := range test.prompts {
|
|
switch pn {
|
|
case "code_review":
|
|
s.AddPrompt(codeReviewPrompt, codReviewPromptHandler)
|
|
default:
|
|
t.Fatalf("unknown prompt %q", pn)
|
|
}
|
|
}
|
|
for _, rn := range test.resources {
|
|
switch rn {
|
|
case "info.txt":
|
|
s.AddResource(resource1, readHandler)
|
|
case "info":
|
|
s.AddResource(resource3, handleEmbeddedResource)
|
|
default:
|
|
t.Fatalf("unknown resource %q", rn)
|
|
}
|
|
}
|
|
|
|
// Connect the server, and connect the client stream,
|
|
// but don't connect an actual client.
|
|
cTransport, sTransport := NewInMemoryTransports()
|
|
ss, err := s.Connect(ctx, sTransport, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cStream, err := cTransport.Connect(ctx)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
writeMsg := func(msg jsonrpc.Message) {
|
|
if err := cStream.Write(ctx, msg); err != nil {
|
|
t.Fatalf("Write failed: %v", err)
|
|
}
|
|
}
|
|
|
|
var (
|
|
serverMessages []jsonrpc.Message
|
|
outRequests []*jsonrpc.Request
|
|
outResponses []*jsonrpc.Response
|
|
)
|
|
|
|
// Separate client requests and responses; we use them differently.
|
|
for _, msg := range test.client {
|
|
switch msg := msg.(type) {
|
|
case *jsonrpc.Request:
|
|
outRequests = append(outRequests, msg)
|
|
case *jsonrpc.Response:
|
|
outResponses = append(outResponses, msg)
|
|
default:
|
|
t.Fatalf("bad message type %T", msg)
|
|
}
|
|
}
|
|
|
|
// nextResponse handles incoming requests and notifications, and returns the
|
|
// next incoming response.
|
|
nextResponse := func() (*jsonrpc.Response, error, bool) {
|
|
for {
|
|
msg, err := cStream.Read(ctx)
|
|
if err != nil {
|
|
// TODO(rfindley): we don't document (or want to document) that the in
|
|
// memory transports use a net.Pipe. How can users detect this failure?
|
|
// Should we promote it to EOF?
|
|
if errors.Is(err, io.ErrClosedPipe) {
|
|
err = nil
|
|
}
|
|
return nil, err, false
|
|
}
|
|
serverMessages = append(serverMessages, msg)
|
|
if req, ok := msg.(*jsonrpc.Request); ok && req.IsCall() {
|
|
// Pair up the next outgoing response with this request.
|
|
// We assume requests arrive in the same order every time.
|
|
if len(outResponses) == 0 {
|
|
t.Fatalf("no outgoing response for request %v", req)
|
|
}
|
|
outResponses[0].ID = req.ID
|
|
writeMsg(outResponses[0])
|
|
outResponses = outResponses[1:]
|
|
continue
|
|
}
|
|
return msg.(*jsonrpc.Response), nil, true
|
|
}
|
|
}
|
|
|
|
// Synthetic peer interacts with real peer.
|
|
for _, req := range outRequests {
|
|
writeMsg(req)
|
|
if req.IsCall() {
|
|
// A call (as opposed to a notification). Wait for the response.
|
|
res, err, ok := nextResponse()
|
|
if err != nil {
|
|
t.Fatalf("reading server messages failed: %v", err)
|
|
}
|
|
if !ok {
|
|
t.Fatalf("missing response for request %v", req)
|
|
}
|
|
if res.ID != req.ID {
|
|
t.Fatalf("out-of-order response %v to request %v", req, res)
|
|
}
|
|
}
|
|
}
|
|
// There might be more notifications or requests, but there shouldn't be more
|
|
// responses.
|
|
// Run this in a goroutine so the current thread can wait for it.
|
|
var extra *jsonrpc.Response
|
|
go func() {
|
|
extra, err, _ = nextResponse()
|
|
}()
|
|
// Before closing the stream, wait for all messages to be processed.
|
|
synctest.Wait()
|
|
if err != nil {
|
|
t.Fatalf("reading server messages failedd: %v", err)
|
|
}
|
|
if extra != nil {
|
|
t.Fatalf("got extra response: %v", extra)
|
|
}
|
|
if err := cStream.Close(); err != nil {
|
|
t.Fatalf("Stream.Close failed: %v", err)
|
|
}
|
|
ss.Wait()
|
|
|
|
// Handle server output. If -update is set, write the 'server' file.
|
|
// Otherwise, compare with expected.
|
|
if *update {
|
|
arch := &txtar.Archive{
|
|
Comment: test.archive.Comment,
|
|
}
|
|
var buf bytes.Buffer
|
|
for _, msg := range serverMessages {
|
|
data, err := jsonrpc2.EncodeIndent(msg, "", "\t")
|
|
if err != nil {
|
|
t.Fatalf("jsonrpc2.EncodeIndent failed: %v", err)
|
|
}
|
|
buf.Write(data)
|
|
buf.WriteByte('\n')
|
|
}
|
|
serverFile := txtar.File{Name: "server", Data: buf.Bytes()}
|
|
seenServer := false // replace or append the 'server' file
|
|
for _, f := range test.archive.Files {
|
|
if f.Name == "server" {
|
|
seenServer = true
|
|
arch.Files = append(arch.Files, serverFile)
|
|
} else {
|
|
arch.Files = append(arch.Files, f)
|
|
}
|
|
}
|
|
if !seenServer {
|
|
arch.Files = append(arch.Files, serverFile)
|
|
}
|
|
if err := os.WriteFile(test.path, txtar.Format(arch), 0o666); err != nil {
|
|
t.Fatalf("os.WriteFile(%q) failed: %v", test.path, err)
|
|
}
|
|
} else {
|
|
// jsonrpc.Messages are not comparable, so we instead compare lines of JSON.
|
|
transform := cmpopts.AcyclicTransformer("toJSON", func(msg jsonrpc.Message) []string {
|
|
encoded, err := jsonrpc2.EncodeIndent(msg, "", "\t")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return strings.Split(string(encoded), "\n")
|
|
})
|
|
if diff := cmp.Diff(test.server, serverMessages, transform); diff != "" {
|
|
t.Errorf("Mismatching server messages (-want +got):\n%s", diff)
|
|
}
|
|
}
|
|
}
|
|
|
|
// loadConformanceTest loads one conformance test from the given path contained
|
|
// in the root dir.
|
|
func loadConformanceTest(dir, path string) (*conformanceTest, error) {
|
|
content, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
test := &conformanceTest{
|
|
name: strings.TrimPrefix(path, dir+string(filepath.Separator)),
|
|
path: path,
|
|
archive: txtar.Parse(content),
|
|
}
|
|
if len(test.archive.Files) == 0 {
|
|
return nil, fmt.Errorf("txtar archive %q has no '-- filename --' sections", path)
|
|
}
|
|
|
|
// decodeMessages loads JSON-RPC messages from the archive file.
|
|
decodeMessages := func(data []byte) ([]jsonrpc.Message, error) {
|
|
dec := json.NewDecoder(bytes.NewReader(data))
|
|
var res []jsonrpc.Message
|
|
for dec.More() {
|
|
var raw json.RawMessage
|
|
if err := dec.Decode(&raw); err != nil {
|
|
return nil, err
|
|
}
|
|
m, err := jsonrpc2.DecodeMessage(raw)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
res = append(res, m)
|
|
}
|
|
return res, nil
|
|
}
|
|
// loadFeatures loads lists of named features from the archive file.
|
|
loadFeatures := func(data []byte) []string {
|
|
var feats []string
|
|
for line := range strings.Lines(string(data)) {
|
|
if f := strings.TrimSpace(line); f != "" {
|
|
feats = append(feats, f)
|
|
}
|
|
}
|
|
return feats
|
|
}
|
|
|
|
seen := make(map[string]bool) // catch accidentally duplicate files
|
|
for _, f := range test.archive.Files {
|
|
if seen[f.Name] {
|
|
return nil, fmt.Errorf("duplicate file name %q", f.Name)
|
|
}
|
|
seen[f.Name] = true
|
|
switch f.Name {
|
|
case "tools":
|
|
test.tools = loadFeatures(f.Data)
|
|
case "prompts":
|
|
test.prompts = loadFeatures(f.Data)
|
|
case "resources":
|
|
test.resources = loadFeatures(f.Data)
|
|
case "client":
|
|
test.client, err = decodeMessages(f.Data)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("txtar archive %q contains bad -- client -- section: %v", path, err)
|
|
}
|
|
case "server":
|
|
test.server, err = decodeMessages(f.Data)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("txtar archive %q contains bad -- server -- section: %v", path, err)
|
|
}
|
|
default:
|
|
return nil, fmt.Errorf("txtar archive %q contains unexpected file %q", path, f.Name)
|
|
}
|
|
}
|
|
|
|
return test, nil
|
|
}
|