This PR adds the protocol version `2026-07-28` to the list of supported protocol versions and fixes legacy tests hardcoding the legacy protocol version for those use cases.
485 lines
14 KiB
Go
485 lines
14 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 (
|
|
"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.
|
|
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
|
|
}
|
|
|
|
func contentTool(ctx context.Context, req *CallToolRequest, args *structuredInput) (*CallToolResult, any, error) {
|
|
return &CallToolResult{
|
|
Content: []Content{
|
|
&ResourceLink{
|
|
Name: "Example Resource Link with Icons",
|
|
MIMEType: "text/plain",
|
|
URI: "https://example.com/resource/" + args.In,
|
|
Icons: []Icon{iconObj},
|
|
},
|
|
},
|
|
}, nil, 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
|
|
}
|
|
|
|
// confirmThenGreet exercises the multi-round-trip pattern in
|
|
// conformance fixtures.
|
|
func confirmThenGreet(_ context.Context, req *CallToolRequest, _ struct{}) (*CallToolResult, any, error) {
|
|
if len(req.Params.InputResponses) == 0 {
|
|
return &CallToolResult{
|
|
InputRequests: InputRequestMap{
|
|
"who": &ElicitParams{
|
|
Message: "What is your name?",
|
|
RequestedSchema: &jsonschema.Schema{
|
|
Type: "object",
|
|
Properties: map[string]*jsonschema.Schema{
|
|
"name": {Type: "string"},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
RequestState: "step=1",
|
|
}, nil, nil
|
|
}
|
|
name := req.Params.InputResponses["who"].(*ElicitResult).Content["name"].(string)
|
|
return &CallToolResult{
|
|
Content: []Content{&TextContent{Text: "hello " + name}},
|
|
}, nil, nil
|
|
}
|
|
|
|
var iconObj = Icon{
|
|
Source: "foobar",
|
|
MIMEType: "image/png",
|
|
Sizes: []string{"48x48", "96x96"},
|
|
Theme: IconThemeLight,
|
|
}
|
|
|
|
// 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.
|
|
impl := &Implementation{Name: "testServer", Version: "v1.0.0"}
|
|
|
|
if test.name == "spec-sep-973-additional-metadata.txtar" {
|
|
impl.Icons = []Icon{iconObj}
|
|
impl.WebsiteURL = "https://github.com/modelcontextprotocol/go-sdk"
|
|
}
|
|
|
|
s := NewServer(impl, nil)
|
|
for _, tn := range test.tools {
|
|
switch tn {
|
|
case "greet":
|
|
AddTool(s, &Tool{
|
|
Name: "greet",
|
|
Description: "say hi",
|
|
}, sayHi)
|
|
case "greetWithIcon":
|
|
AddTool(s, &Tool{
|
|
Name: "greetWithIcon",
|
|
Description: "say hi",
|
|
Icons: []Icon{iconObj},
|
|
}, 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)
|
|
case "contentTool":
|
|
AddTool(s, &Tool{
|
|
Name: "contentTool",
|
|
Title: "contentTool",
|
|
Description: "return resourceLink content with Icon",
|
|
}, contentTool)
|
|
case "confirmThenGreet":
|
|
AddTool(s, &Tool{
|
|
Name: "confirmThenGreet",
|
|
Description: "ask the user for their name via elicitation, then greet them",
|
|
}, confirmThenGreet)
|
|
default:
|
|
t.Fatalf("unknown tool %q", tn)
|
|
}
|
|
}
|
|
for _, pn := range test.prompts {
|
|
switch pn {
|
|
case "code_review":
|
|
s.AddPrompt(codeReviewPrompt, codReviewPromptHandler)
|
|
case "code_reviewWithIcon":
|
|
s.AddPrompt(&Prompt{
|
|
Name: "code_review",
|
|
Description: "do a code review",
|
|
Arguments: []*PromptArgument{{Name: "Code", Required: true}},
|
|
Icons: []Icon{iconObj},
|
|
}, 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)
|
|
case "infoWithIcon":
|
|
s.AddResource(&Resource{
|
|
Name: "info",
|
|
MIMEType: "text/plain",
|
|
URI: "embedded:info",
|
|
Icons: []Icon{iconObj},
|
|
}, 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 failed: %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
|
|
}
|