go-sdk/mcp/mcp_test.go
Rob Findley ef446f82d7 internal/mcp: implement HTTP SSE transport
Add support for SSE transport, as defined by version 2024-11-05 and
2025-03-26 of the MCP spec.

In order to achieve this, the jsonrpc2 stream must be abstracted so that
we can implement it across multiple HTTP requests. This required some
refactoring of the jsonrpc2_v2 package, so that we can avoid the
Dialer/Binder interfaces and construct the Connection directly from its
constituent primitives.

Change-Id: Id627b5b5a474d92f2c613cfad904d14611e7c768
Reviewed-on: https://go-review.googlesource.com/c/tools/+/667475
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:20 -07:00

202 lines
4.8 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"
"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(_ context.Context, v hiParams) ([]Content, error) {
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, 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))
}
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:
}
}
}
}