go-sdk/mcp/conformance_test.go
Jonathan Amsterdam a627a4a2dd internal/mcp: add resource conformance test
This uncovered a problem in how we represent resource results:
contents is a slice, not a single value.

I tried to use a file resource, but the server->client ListRoots
call didn't work because synthetic responses aren't supported.
Left a TODO.

I enhanced the hello example so I that could use the MCP Inspector to
work with resources.

The falseSchema change fixes a potential bug where schemas are
re-used, which is disallowed.

Change-Id: I1f0198043cb6221c2a03f4f83aa0bafbc71167f4
Reviewed-on: https://go-review.googlesource.com/c/tools/+/677136
Reviewed-by: Robert Findley <rfindley@google.com>
Reviewed-by: Sam Thanawalla <samthanawalla@google.com>
LUCI-TryBot-Result: Go LUCI <golang-scoped@luci-project-accounts.iam.gserviceaccount.com>
Auto-Submit: Jonathan Amsterdam <jba@google.com>
2025-05-29 11:59:43 -07:00

284 lines
8.5 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
package mcp
import (
"bytes"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"io/fs"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"testing/synctest"
"github.com/google/go-cmp/cmp"
"github.com/google/go-cmp/cmp/cmpopts"
"github.com/modelcontextprotocol/go-sdk/internal/jsonrpc2"
"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 []jsonrpc2.Message // client messages
server []jsonrpc2.Message // server messages
}
// TODO(jba): support synthetic responses.
// 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.
synctest.Run(func() { runServerTest(t, test) })
// TODO: in 1.25, use the following instead:
// synctest.Test(t, func(t *testing.T) {
// runServerTest(t, test)
// })
})
}
}
// 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("testServer", "v1.0.0", nil)
add(tools, s.AddTools, test.tools...)
add(prompts, s.AddPrompts, test.prompts...)
add(resources, s.AddResources, test.resources...)
// Connect the server, and connect the client stream,
// but don't connect an actual client.
cTransport, sTransport := NewInMemoryTransports()
ss, err := s.Connect(ctx, sTransport)
if err != nil {
t.Fatal(err)
}
cStream, err := cTransport.Connect(ctx)
if err != nil {
t.Fatal(err)
}
// Collect server messages asynchronously.
var wg sync.WaitGroup
var (
serverMessages []jsonrpc2.Message
serverErr error // abnormal failure of the server stream
)
wg.Add(1)
go func() {
defer wg.Done()
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) {
serverErr = err
}
break
}
serverMessages = append(serverMessages, msg)
}
}()
// Write client messages to the stream.
for _, msg := range test.client {
if _, err := cStream.Write(ctx, msg); err != nil {
t.Fatalf("Write failed: %v", err)
}
}
// Before closing the stream, wait for all messages to be processed.
synctest.Wait()
if err := cStream.Close(); err != nil {
t.Fatalf("Stream.Close failed: %v", err)
}
ss.Wait()
wg.Wait()
if serverErr != nil {
t.Fatalf("reading server messages failed: %v", serverErr)
}
// 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 {
// jsonrpc2.Messages are not comparable, so we instead compare lines of JSON.
transform := cmpopts.AcyclicTransformer("toJSON", func(msg jsonrpc2.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) ([]jsonrpc2.Message, error) {
dec := json.NewDecoder(bytes.NewReader(data))
var res []jsonrpc2.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
}