Also validate that requests observe the lifecycle rules defined in the spec. Change-Id: I302282f81e053e76926e0bb295f7dbf8d526f02d Reviewed-on: https://go-review.googlesource.com/c/tools/+/667575 Reviewed-by: Jonathan Amsterdam <jba@google.com> Commit-Queue: Robert Findley <rfindley@google.com> Auto-Submit: Robert Findley <rfindley@google.com> TryBot-Bypass: Robert Findley <rfindley@google.com>
211 lines
5 KiB
Go
211 lines
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.
|
|
|
|
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")
|
|
}
|
|
}
|
|
|
|
func TestServerClosing(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))
|
|
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)
|
|
}
|
|
|
|
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:
|
|
}
|
|
}
|
|
}
|
|
|
|
}
|