340 lines
8.6 KiB
Go
340 lines
8.6 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_test
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"log"
|
|
"os"
|
|
"os/exec"
|
|
"os/signal"
|
|
"runtime"
|
|
"syscall"
|
|
"testing"
|
|
"testing/synctest"
|
|
"time"
|
|
|
|
"github.com/google/go-cmp/cmp"
|
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
|
)
|
|
|
|
const runAsServer = "_MCP_RUN_AS_SERVER"
|
|
|
|
type SayHiParams struct {
|
|
Name string `json:"name"`
|
|
}
|
|
|
|
func SayHi(ctx context.Context, req *mcp.CallToolRequest, args SayHiParams) (*mcp.CallToolResult, any, error) {
|
|
return &mcp.CallToolResult{
|
|
Content: []mcp.Content{
|
|
&mcp.TextContent{Text: "Hi " + args.Name},
|
|
},
|
|
}, nil, nil
|
|
}
|
|
|
|
func TestMain(m *testing.M) {
|
|
// If the runAsServer variable is set, execute the relevant serverFunc
|
|
// instead of running tests (aka the fork and exec trick).
|
|
if name := os.Getenv(runAsServer); name != "" {
|
|
run := serverFuncs[name]
|
|
if run == nil {
|
|
log.Fatalf("Unknown server %q", name)
|
|
}
|
|
os.Unsetenv(runAsServer)
|
|
run()
|
|
return
|
|
}
|
|
os.Exit(m.Run())
|
|
}
|
|
|
|
// serverFuncs defines server functions that may be run as subprocesses via
|
|
// [TestMain].
|
|
var serverFuncs = map[string]func(){
|
|
"default": runServer,
|
|
"cancelContext": runCancelContextServer,
|
|
}
|
|
|
|
func runServer() {
|
|
ctx := context.Background()
|
|
|
|
server := mcp.NewServer(testImpl, nil)
|
|
mcp.AddTool(server, &mcp.Tool{Name: "greet", Description: "say hi"}, SayHi)
|
|
if err := server.Run(ctx, &mcp.StdioTransport{}); err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func runCancelContextServer() {
|
|
ctx, done := signal.NotifyContext(context.Background(), syscall.SIGINT)
|
|
defer done()
|
|
|
|
server := mcp.NewServer(testImpl, nil)
|
|
if err := server.Run(ctx, &mcp.StdioTransport{}); err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func TestServerRunContextCancel(t *testing.T) {
|
|
synctest.Test(t, func(t *testing.T) {
|
|
server := mcp.NewServer(&mcp.Implementation{Name: "greeter", Version: "v0.0.1"}, nil)
|
|
mcp.AddTool(server, &mcp.Tool{Name: "greet", Description: "say hi"}, SayHi)
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
|
|
serverTransport, clientTransport := mcp.NewInMemoryTransports()
|
|
|
|
// run the server and capture the exit error
|
|
onServerExit := make(chan error)
|
|
go func() {
|
|
onServerExit <- server.Run(ctx, serverTransport)
|
|
}()
|
|
|
|
// send a ping to the server to ensure it's running
|
|
client := mcp.NewClient(&mcp.Implementation{Name: "client", Version: "v0.0.1"}, nil)
|
|
session, err := client.Connect(ctx, clientTransport, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { session.Close() })
|
|
|
|
if err := session.Ping(context.Background(), nil); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// cancel the context to stop the server
|
|
cancel()
|
|
|
|
// wait for the server to exit
|
|
|
|
err = <-onServerExit
|
|
if !errors.Is(err, context.Canceled) {
|
|
t.Fatalf("server did not exit after context cancellation, got error: %v", err)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestServerInterrupt(t *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("requires POSIX signals")
|
|
}
|
|
requireExec(t)
|
|
|
|
t.Log("Starting server command")
|
|
cmd := createServerCommand(t, "default")
|
|
|
|
client := mcp.NewClient(testImpl, nil)
|
|
t.Log("Connecting to server")
|
|
|
|
ctx := context.Background()
|
|
session, err := client.Connect(ctx, &mcp.CommandTransport{Command: cmd}, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
t.Log("Send a signal to the server process to terminate it")
|
|
if err := cmd.Process.Signal(os.Interrupt); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
t.Log("Closing client session so server can exit immediately")
|
|
session.Close()
|
|
|
|
t.Log("Wait for process to terminate after interrupt signal")
|
|
_, err = cmd.Process.Wait()
|
|
if err == nil {
|
|
t.Errorf("unexpected error: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestStdioContextCancellation(t *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("requires POSIX signals")
|
|
}
|
|
requireExec(t)
|
|
|
|
// This test is a variant of TestServerInterrupt reproducing the conditions
|
|
// of #224, where interrupt failed to shut down the server because reads of
|
|
// Stdin were not unblocked.
|
|
|
|
cmd := createServerCommand(t, "cancelContext")
|
|
// Creating a stdin pipe causes os.Stdin.Close to not immediately unblock
|
|
// pending reads.
|
|
_, _ = cmd.StdinPipe()
|
|
|
|
// Just Start the command, rather than connecting to the server, because we
|
|
// don't want the client connection to indirectly flush stdin through writes.
|
|
if err := cmd.Start(); err != nil {
|
|
t.Fatalf("starting command: %v", err)
|
|
}
|
|
|
|
// Sleep to make it more likely that the server is blocked in the read loop.
|
|
//
|
|
// This sleep isn't necessary for the test to pass, but *was* necessary for
|
|
// it to fail, before closing was fixed. Unfortunately, it is too invasive a
|
|
// change to have the jsonrpc2 package signal across packages when it is
|
|
// actually blocked in its read loop.
|
|
time.Sleep(100 * time.Millisecond)
|
|
|
|
onExit := make(chan struct{})
|
|
go func() {
|
|
cmd.Process.Wait()
|
|
close(onExit)
|
|
}()
|
|
|
|
if err := cmd.Process.Signal(os.Interrupt); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
select {
|
|
case <-time.After(5 * time.Second):
|
|
t.Fatal("server did not exit after SIGINT")
|
|
case <-onExit:
|
|
t.Logf("done.")
|
|
}
|
|
}
|
|
|
|
func TestCmdTransport(t *testing.T) {
|
|
requireExec(t)
|
|
|
|
ctx := t.Context()
|
|
|
|
cmd := createServerCommand(t, "default")
|
|
|
|
client := mcp.NewClient(&mcp.Implementation{Name: "client", Version: "v0.0.1"}, nil)
|
|
session, err := client.Connect(ctx, &mcp.CommandTransport{Command: cmd}, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got, err := session.CallTool(ctx, &mcp.CallToolParams{
|
|
Name: "greet",
|
|
Arguments: map[string]any{"name": "user"},
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
want := &mcp.CallToolResult{
|
|
Content: []mcp.Content{
|
|
&mcp.TextContent{Text: "Hi user"},
|
|
},
|
|
}
|
|
if diff := cmp.Diff(want, got, ctrCmpOpts...); diff != "" {
|
|
t.Errorf("greet returned unexpected content (-want +got):\n%s", diff)
|
|
}
|
|
if err := session.Close(); err != nil {
|
|
t.Fatalf("closing server: %v", err)
|
|
}
|
|
}
|
|
|
|
// createServerCommand creates a command to fork and exec the test binary as an
|
|
// MCP server.
|
|
//
|
|
// serverName must refer to an entry in the [serverFuncs] map.
|
|
func createServerCommand(t *testing.T, serverName string) *exec.Cmd {
|
|
t.Helper()
|
|
|
|
exe, err := os.Executable()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cmd := exec.Command(exe)
|
|
cmd.Env = append(os.Environ(), runAsServer+"="+serverName)
|
|
|
|
return cmd
|
|
}
|
|
|
|
func TestCommandTransportTerminateDuration(t *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("requires POSIX signals")
|
|
}
|
|
requireExec(t)
|
|
|
|
// Unfortunately, since it does I/O, this test needs to rely on timing (we
|
|
// can't use synctest). However, we can still decrease the default
|
|
// termination duration to speed up the test.
|
|
const defaultDur = 50 * time.Millisecond
|
|
defer mcp.SetDefaultTerminateDuration(defaultDur)()
|
|
|
|
tests := []struct {
|
|
name string
|
|
duration time.Duration
|
|
wantMinDuration time.Duration
|
|
wantMaxDuration time.Duration
|
|
}{
|
|
{
|
|
name: "default duration (zero)",
|
|
duration: 0,
|
|
wantMinDuration: defaultDur,
|
|
wantMaxDuration: 1 * time.Second, // default + buffer
|
|
},
|
|
{
|
|
name: "below minimum duration",
|
|
duration: -500 * time.Millisecond,
|
|
wantMinDuration: defaultDur,
|
|
wantMaxDuration: 1 * time.Second, // should use default + buffer
|
|
},
|
|
{
|
|
name: "custom valid duration",
|
|
duration: 200 * time.Millisecond,
|
|
wantMinDuration: 200 * time.Millisecond,
|
|
wantMaxDuration: 1 * time.Second, // custom + buffer
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
ctx := t.Context()
|
|
|
|
// Use a command that won't exit when stdin is closed
|
|
cmd := exec.Command("sleep", "20")
|
|
transport := &mcp.CommandTransport{
|
|
Command: cmd,
|
|
TerminateDuration: tt.duration,
|
|
}
|
|
|
|
conn, err := transport.Connect(ctx)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
start := time.Now()
|
|
err = conn.Close()
|
|
elapsed := time.Since(start)
|
|
|
|
if err != nil {
|
|
var exitErr *exec.ExitError
|
|
if !errors.As(err, &exitErr) {
|
|
t.Fatalf("Close() failed with unexpected error: %v", err)
|
|
}
|
|
}
|
|
if elapsed < tt.wantMinDuration {
|
|
t.Errorf("Close() took %v, expected at least %v", elapsed, tt.wantMinDuration)
|
|
}
|
|
if elapsed > tt.wantMaxDuration {
|
|
t.Errorf("Close() took %v, expected at most %v", elapsed, tt.wantMaxDuration)
|
|
}
|
|
|
|
// Ensure the process was actually terminated
|
|
if cmd.Process != nil {
|
|
cmd.Process.Kill()
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func requireExec(t *testing.T) {
|
|
t.Helper()
|
|
|
|
// Conservatively, limit to major OS where we know that os.Exec is
|
|
// supported.
|
|
switch runtime.GOOS {
|
|
case "darwin", "linux", "windows":
|
|
default:
|
|
t.Skip("unsupported OS")
|
|
}
|
|
}
|
|
|
|
var testImpl = &mcp.Implementation{Name: "test", Version: "v1.0.0"}
|