// 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)
}
}
// TODO: remove this test when Go 1.24 support is dropped (use go1.25 synctest version).
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)
}
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
// TODO: use synctest when available
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)
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, 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, 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 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"}