go-sdk/mcp/cmd_test.go
Rob Findley 159933cfa3 mcp: speed up TestCommandTransportDuration
At 7s, this test was 70% of our test execution time on my computer.
Speed it up by mutating the defaultTerminateDuration for the purposes of
the test.

Also update our test workflow to use go1.25, now that it's out.
2025-09-12 12:00:29 -04:00

346 lines
8.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_test
import (
"context"
"errors"
"log"
"os"
"os/exec"
"os/signal"
"runtime"
"syscall"
"testing"
"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) {
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)
}
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
// TODO: use synctest when availble
select {
case <-time.After(5 * time.Second):
t.Fatal("server did not exit after context cancellation")
case 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)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
cmd := createServerCommand(t, "default")
client := mcp.NewClient(testImpl, nil)
_, err := client.Connect(ctx, &mcp.CommandTransport{Command: cmd}, nil)
if err != nil {
t.Fatal(err)
}
// get a signal when the server process exits
onExit := make(chan struct{})
go func() {
cmd.Process.Wait()
close(onExit)
}()
// send a signal to the server process to terminate it
if err := cmd.Process.Signal(os.Interrupt); err != nil {
t.Fatal(err)
}
// wait for the server to exit
// TODO: use synctest when available
select {
case <-time.After(5 * time.Second):
t.Fatal("server did not exit after SIGINT")
case <-onExit:
}
}
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, cancel := context.WithCancel(context.Background())
defer cancel()
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); 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 decreate 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, cancel := context.WithCancel(context.Background())
defer cancel()
// 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"}