go-sdk/mcp/mcp_test.go
Rob Findley 14b18b2645 internal/mcp: implement cancellation
Use the existing jsonrpc2 preemption mechanism to implement MCP
cancellation.

This was mostly straightforward, except where I got confused about ID
unmarshaling. Leave a note about using omitempty when it is available.

Change-Id: I13e073f08c5d5c2cc78d882da4e6ff47f09fb340
Reviewed-on: https://go-review.googlesource.com/c/tools/+/667578
LUCI-TryBot-Result: Go LUCI <golang-scoped@luci-project-accounts.iam.gserviceaccount.com>
Reviewed-by: Jonathan Amsterdam <jba@google.com>
2025-04-23 19:15:39 -07:00

255 lines
6.2 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"
"errors"
"fmt"
"slices"
"strings"
"sync"
"testing"
"time"
"github.com/google/go-cmp/cmp"
"github.com/modelcontextprotocol/go-sdk/internal/jsonschema"
"github.com/modelcontextprotocol/go-sdk/internal/protocol"
)
type hiParams struct {
Name string
}
func sayHi(ctx context.Context, cc *ClientConnection, v hiParams) ([]Content, error) {
if err := cc.Ping(ctx); err != nil {
return nil, fmt.Errorf("ping failed: %v", err)
}
return []Content{TextContent{Text: "hi " + v.Name}}, nil
}
func TestEndToEnd(t *testing.T) {
ctx := context.Background()
ct, st := NewLocalTransport()
s := NewServer("testServer", "v1.0.0", nil)
// The 'greet' tool says hi.
s.AddTools(MakeTool("greet", "say hi", sayHi))
// The 'fail' tool returns this error.
failure := errors.New("mcp failure")
s.AddTools(MakeTool("fail", "just fail", func(context.Context, *ClientConnection, struct{}) ([]Content, error) {
return nil, failure
}))
// Connect the server.
cc, err := s.Connect(ctx, st, nil)
if err != nil {
t.Fatal(err)
}
if got := slices.Collect(s.Clients()); len(got) != 1 {
t.Errorf("after connection, Clients() has length %d, want 1", len(got))
}
// Wait for the server to exit after the client closes its connection.
var clientWG sync.WaitGroup
clientWG.Add(1)
go func() {
if err := cc.Wait(); err != nil {
t.Errorf("server failed: %v", err)
}
clientWG.Done()
}()
c := NewClient("testClient", "v1.0.0", nil)
// Connect the client.
sc, err := c.Connect(ctx, ct, nil)
if err != nil {
t.Fatal(err)
}
if got := slices.Collect(c.Servers()); len(got) != 1 {
t.Errorf("after connection, Servers() has length %d, want 1", len(got))
}
if err := sc.Ping(ctx); err != nil {
t.Fatalf("ping failed: %v", err)
}
gotTools, err := sc.ListTools(ctx)
if err != nil {
t.Errorf("tools/list failed: %v", err)
}
wantTools := []protocol.Tool{{
Name: "greet",
Description: "say hi",
InputSchema: &jsonschema.Schema{
Type: "object",
Properties: map[string]*jsonschema.Schema{
"Name": {Type: "string"},
},
AdditionalProperties: false,
},
}, {
Name: "fail",
Description: "just fail",
InputSchema: &jsonschema.Schema{
Type: "object",
AdditionalProperties: false,
},
}}
if diff := cmp.Diff(wantTools, gotTools); diff != "" {
t.Fatalf("tools/list mismatch (-want +got):\n%s", diff)
}
gotHi, err := sc.CallTool(ctx, "greet", hiParams{"user"})
if err != nil {
t.Fatal(err)
}
wantHi := []Content{TextContent{Text: "hi user"}}
if diff := cmp.Diff(wantHi, gotHi); diff != "" {
t.Errorf("tools/call 'greet' mismatch (-want +got):\n%s", diff)
}
if _, err := sc.CallTool(ctx, "fail", struct{}{}); err == nil || !strings.Contains(err.Error(), failure.Error()) {
t.Errorf("fail returned unexpected error: got %v, want containing %v", err, failure)
}
// Disconnect.
sc.Close()
clientWG.Wait()
// After disconnecting, neither client nor server should have any
// connections.
for range s.Clients() {
t.Errorf("unexpected client after disconnection")
}
for range c.Servers() {
t.Errorf("unexpected server after disconnection")
}
}
// basicConnection returns a new basic client-server connection configured with
// the provided tools.
//
// The caller should cancel either the client connection or server connection
// when the connections are no longer needed.
func basicConnection(t *testing.T, tools ...*Tool) (*ClientConnection, *ServerConnection) {
t.Helper()
ctx := context.Background()
ct, st := NewLocalTransport()
s := NewServer("testServer", "v1.0.0", nil)
// The 'greet' tool says hi.
s.AddTools(tools...)
cc, err := s.Connect(ctx, st, nil)
if err != nil {
t.Fatal(err)
}
c := NewClient("testClient", "v1.0.0", nil)
sc, err := c.Connect(ctx, ct, nil)
if err != nil {
t.Fatal(err)
}
return cc, sc
}
func TestServerClosing(t *testing.T) {
cc, sc := basicConnection(t, MakeTool("greet", "say hi", sayHi))
defer sc.Close()
ctx := context.Background()
var wg sync.WaitGroup
wg.Add(1)
go func() {
if err := sc.Wait(); err != nil {
t.Errorf("server connection failed: %v", err)
}
wg.Done()
}()
if _, err := sc.CallTool(ctx, "greet", hiParams{"user"}); err != nil {
t.Fatalf("after connecting: %v", err)
}
cc.Close()
wg.Wait()
if _, err := sc.CallTool(ctx, "greet", hiParams{"user"}); !errors.Is(err, ErrConnectionClosed) {
t.Errorf("after disconnection, got error %v, want EOF", err)
}
}
func TestBatching(t *testing.T) {
ctx := context.Background()
ct, st := NewLocalTransport()
s := NewServer("testServer", "v1.0.0", nil)
_, err := s.Connect(ctx, st, nil)
if err != nil {
t.Fatal(err)
}
c := NewClient("testClient", "v1.0.0", nil)
opts := new(ConnectionOptions)
// TODO: this test is broken, because increasing the batch size here causes
// 'initialize' to block. Therefore, we can only test with a size of 1.
const batchSize = 1
BatchSize(ct, batchSize)
sc, err := c.Connect(ctx, ct, opts)
if err != nil {
t.Fatal(err)
}
defer sc.Close()
errs := make(chan error, batchSize)
for i := range batchSize {
go func() {
_, err := sc.ListTools(ctx)
errs <- err
}()
time.Sleep(2 * time.Millisecond)
if i < batchSize-1 {
select {
case <-errs:
t.Errorf("ListTools: unexpected result for incomplete batch: %v", err)
default:
}
}
}
}
func TestCancellation(t *testing.T) {
var (
start = make(chan struct{})
cancelled = make(chan struct{}, 1) // don't block the request
)
slowRequest := func(ctx context.Context, cc *ClientConnection, v struct{}) ([]Content, error) {
start <- struct{}{}
select {
case <-ctx.Done():
cancelled <- struct{}{}
case <-time.After(5 * time.Second):
return nil, nil
}
return nil, nil
}
_, sc := basicConnection(t, MakeTool("slow", "a slow request", slowRequest))
defer sc.Close()
ctx, cancel := context.WithCancel(context.Background())
go sc.CallTool(ctx, "slow", struct{}{})
<-start
cancel()
select {
case <-cancelled:
case <-time.After(5 * time.Second):
t.Fatal("timeout waiting for cancellation")
}
}