Skip to content

Commit c3afa82

Browse files
Merge branch 'main' into mkisiel/ent-tests
2 parents bfe36e2 + 862d78a commit c3afa82

7 files changed

Lines changed: 111 additions & 59 deletions

File tree

mcp/client.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -340,7 +340,8 @@ func (cs *ClientSession) ID() string {
340340
// Close is idempotent and concurrency safe.
341341
func (cs *ClientSession) Close() error {
342342
// Note: keepaliveCancel access is safe without a mutex because:
343-
// 1. keepaliveCancel is only written once during startKeepalive (happens-before all Close calls)
343+
// 1. keepaliveCancel is only written once during Client.Connect (through startKeepalive),
344+
// which happens before any code that may call Close from another goroutine
344345
// 2. context.CancelFunc is safe to call multiple times and from multiple goroutines
345346
// 3. The keepalive goroutine calls Close on ping failure, but this is safe since
346347
// Close is idempotent and conn.Close() handles concurrent calls correctly

mcp/mcp_test.go

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2228,4 +2228,45 @@ func TestToolErrorMiddleware(t *testing.T) {
22282228
}
22292229
}
22302230

2231+
func TestSetErrorPreservesContent(t *testing.T) {
2232+
for _, tt := range []struct {
2233+
name string
2234+
content []Content
2235+
err error
2236+
wantContent string
2237+
}{
2238+
{
2239+
name: "nil content",
2240+
err: errors.New("internal failure"),
2241+
wantContent: "internal failure",
2242+
},
2243+
{
2244+
name: "empty slice content",
2245+
content: []Content{},
2246+
err: errors.New("internal failure"),
2247+
wantContent: "internal failure",
2248+
},
2249+
{
2250+
name: "existing content preserved",
2251+
content: []Content{&TextContent{Text: "user-friendly msg"}},
2252+
err: errors.New("db timeout"),
2253+
wantContent: "user-friendly msg",
2254+
},
2255+
} {
2256+
t.Run(tt.name, func(t *testing.T) {
2257+
res := CallToolResult{Content: tt.content}
2258+
res.SetError(tt.err)
2259+
if !res.IsError {
2260+
t.Fatal("want IsError=true")
2261+
}
2262+
if got := res.Content[0].(*TextContent).Text; got != tt.wantContent {
2263+
t.Errorf("Content text = %q, want %q", got, tt.wantContent)
2264+
}
2265+
if got := res.GetError(); got != tt.err {
2266+
t.Errorf("GetError() = %v, want %v", got, tt.err)
2267+
}
2268+
})
2269+
}
2270+
}
2271+
22312272
var ctrCmpOpts = []cmp.Option{cmp.AllowUnexported(CallToolResult{})}

mcp/protocol.go

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
"maps"
1111

1212
internaljson "github.com/modelcontextprotocol/go-sdk/internal/json"
13+
"github.com/modelcontextprotocol/go-sdk/internal/mcpgodebug"
1314
)
1415

1516
// Optional annotations for the client. The client can use annotations to inform
@@ -113,10 +114,25 @@ type CallToolResult struct {
113114
err error
114115
}
115116

116-
// SetError sets the error for the tool result and populates the Content field
117-
// with the error text. It also sets IsError to true.
117+
// seterroroverwrite is a compatibility parameter that restores the pre-1.6.0
118+
// behavior of [CallToolResult.SetError], where Content was always overwritten
119+
// with the error text. See the documentation for the mcpgodebug package for
120+
// instructions on how to enable it.
121+
// The option will be removed in the 1.8.0 version of the SDK.
122+
var seterroroverwrite = mcpgodebug.Value("seterroroverwrite")
123+
124+
// SetError sets the error for the tool result and sets IsError to true.
125+
// If Content has not already been populated, it is set to the error text.
126+
// If Content has already been populated, it is left unchanged, allowing callers
127+
// to provide a user-friendly message while still recording the underlying error
128+
// for inspection via [GetError] in server middleware.
129+
//
130+
// To restore the previous behavior where Content was always overwritten,
131+
// set MCPGODEBUG=seterroroverwrite=1.
118132
func (r *CallToolResult) SetError(err error) {
119-
r.Content = []Content{&TextContent{Text: err.Error()}}
133+
if len(r.Content) == 0 || seterroroverwrite == "1" {
134+
r.Content = []Content{&TextContent{Text: err.Error()}}
135+
}
120136
r.IsError = true
121137
r.err = err
122138
}

mcp/server.go

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1031,6 +1031,13 @@ func (s *Server) Connect(ctx context.Context, t Transport, opts *ServerSessionOp
10311031
s.opts.Logger.Error("server connect error", "error", err)
10321032
return nil, err
10331033
}
1034+
1035+
// Start keepalive before returning the session to avoid race conditions with Close.
1036+
// This is safe because the spec allows sending pings before initialization (see ServerSession.handle for details).
1037+
if s.opts.KeepAlive > 0 {
1038+
ss.startKeepalive(ss.server.opts.KeepAlive)
1039+
}
1040+
10341041
return ss, nil
10351042
}
10361043

@@ -1058,9 +1065,6 @@ func (ss *ServerSession) initialized(ctx context.Context, params *InitializedPar
10581065
ss.server.opts.Logger.Error("duplicate initialized notification")
10591066
return nil, fmt.Errorf("duplicate %q received", notificationInitialized)
10601067
}
1061-
if ss.server.opts.KeepAlive > 0 {
1062-
ss.startKeepalive(ss.server.opts.KeepAlive)
1063-
}
10641068
if h := ss.server.opts.InitializedHandler; h != nil {
10651069
h(ctx, serverRequestFor(ss, params))
10661070
}
@@ -1110,7 +1114,7 @@ type ServerSession struct {
11101114
server *Server
11111115
conn *jsonrpc2.Connection
11121116
mcpConn Connection
1113-
keepaliveCancel context.CancelFunc // TODO: theory around why keepaliveCancel need not be guarded
1117+
keepaliveCancel context.CancelFunc
11141118

11151119
mu sync.Mutex
11161120
state ServerSessionState
@@ -1504,7 +1508,8 @@ func (ss *ServerSession) setLevel(_ context.Context, params *SetLoggingLevelPara
15041508
func (ss *ServerSession) Close() error {
15051509
if ss.keepaliveCancel != nil {
15061510
// Note: keepaliveCancel access is safe without a mutex because:
1507-
// 1. keepaliveCancel is only written once during startKeepalive (happens-before all Close calls)
1511+
// 1. keepaliveCancel is only written once during Server.Connect (through startKeepalive),
1512+
// which happens before any code that may call Close from another goroutine
15081513
// 2. context.CancelFunc is safe to call multiple times and from multiple goroutines
15091514
// 3. The keepalive goroutine calls Close on ping failure, but this is safe since
15101515
// Close is idempotent and conn.Close() handles concurrent calls correctly

mcp/server_test.go

Lines changed: 0 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -508,54 +508,6 @@ func TestServerAddResourceTemplate(t *testing.T) {
508508
}
509509
}
510510

511-
// TestServerSessionkeepaliveCancelOverwritten is to verify that `ServerSession.keepaliveCancel` is assigned exactly once,
512-
// ensuring that only a single goroutine is responsible for the session's keepalive ping mechanism.
513-
func TestServerSessionkeepaliveCancelOverwritten(t *testing.T) {
514-
// Set KeepAlive to a long duration to ensure the keepalive
515-
// goroutine stays alive for the duration of the test without actually sending
516-
// ping requests, since we don't have a real client connection established.
517-
server := NewServer(testImpl, &ServerOptions{KeepAlive: 5 * time.Second})
518-
ss := &ServerSession{server: server}
519-
520-
// 1. Initialize the session.
521-
_, err := ss.initialize(context.Background(), &InitializeParams{})
522-
if err != nil {
523-
t.Fatalf("ServerSession initialize failed: %v", err)
524-
}
525-
526-
// 2. Call 'initialized' for the first time. This should start the keepalive mechanism.
527-
_, err = ss.initialized(context.Background(), &InitializedParams{})
528-
if err != nil {
529-
t.Fatalf("First initialized call failed: %v", err)
530-
}
531-
if ss.keepaliveCancel == nil {
532-
t.Fatalf("expected ServerSession.keepaliveCancel to be set after the first call of initialized")
533-
}
534-
535-
// Save the cancel function and use defer to ensure resources are cleaned up.
536-
firstCancel := ss.keepaliveCancel
537-
defer firstCancel()
538-
539-
// 3. Manually set the field to nil.
540-
// Do this to facilitate the test's core assertion. The goal is to verify that
541-
// 'ss.keepaliveCancel' is not assigned a second time. By setting it to nil,
542-
// we can easily check after the next call if a new keepalive goroutine was started.
543-
ss.keepaliveCancel = nil
544-
545-
// 4. Call 'initialized' for the second time. This should return an error.
546-
_, err = ss.initialized(context.Background(), &InitializedParams{})
547-
if err == nil {
548-
t.Fatalf("Expected 'duplicate initialized received' error on second call, got nil")
549-
}
550-
551-
// 5. Re-check the field to ensure it remains nil.
552-
// Since 'initialized' correctly returned an error and did not call
553-
// 'startKeepalive', the field should remain unchanged.
554-
if ss.keepaliveCancel != nil {
555-
t.Fatal("expected ServerSession.keepaliveCancel to be nil after we manually niled it and re-initialized")
556-
}
557-
}
558-
559511
// panicks reports whether f() panics.
560512
func panics(f func()) (b bool) {
561513
defer func() {

mcp/streamable.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import (
2020
"maps"
2121
"math"
2222
"math/rand/v2"
23+
"mime"
2324
"net"
2425
"net/http"
2526
"slices"
@@ -264,8 +265,8 @@ func (h *StreamableHTTPHandler) ServeHTTP(w http.ResponseWriter, req *http.Reque
264265
}
265266
// Validate 'Content-Type' header.
266267
if req.Method == http.MethodPost {
267-
contentType := req.Header.Get("Content-Type")
268-
if contentType != "application/json" {
268+
mediaType, _, err := mime.ParseMediaType(req.Header.Get("Content-Type"))
269+
if err != nil || mediaType != "application/json" {
269270
http.Error(w, "Content-Type must be 'application/json'", http.StatusUnsupportedMediaType)
270271
return
271272
}

mcp/streamable_test.go

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -363,6 +363,34 @@ func TestStreamableServerShutdown(t *testing.T) {
363363
}
364364
}
365365

366+
// TestStreamableStatelessKeepaliveRace verifies that there is no data race between
367+
// ServerSession.startKeepalive and ServerSession.Close in stateless servers.
368+
func TestStreamableStatelessKeepaliveRace(t *testing.T) {
369+
ctx := context.Background()
370+
server := NewServer(testImpl, &ServerOptions{KeepAlive: time.Hour})
371+
AddTool(server, &Tool{Name: "greet"}, sayHi)
372+
handler := NewStreamableHTTPHandler(
373+
func(*http.Request) *Server { return server },
374+
&StreamableHTTPOptions{Stateless: true},
375+
)
376+
httpServer := httptest.NewServer(mustNotPanic(t, handler))
377+
defer httpServer.Close()
378+
379+
for range 50 {
380+
cs, err := NewClient(testImpl, nil).Connect(ctx, &StreamableClientTransport{
381+
Endpoint: httpServer.URL,
382+
}, nil)
383+
if err != nil {
384+
t.Fatalf("NewClient() failed: %v", err)
385+
}
386+
_, _ = cs.CallTool(ctx, &CallToolParams{
387+
Name: "greet",
388+
Arguments: map[string]any{"Name": "world"},
389+
})
390+
_ = cs.Close()
391+
}
392+
}
393+
366394
// TestClientReplay verifies that the client can recover from a mid-stream
367395
// network failure and receive replayed messages (if replay is configured). It
368396
// uses a proxy that is killed and restarted to simulate a recoverable network
@@ -862,6 +890,14 @@ func TestStreamableServerTransport(t *testing.T) {
862890
wantStatusCode: http.StatusOK,
863891
wantMessages: []jsonrpc.Message{resp(5, &CallToolResult{Content: []Content{}}, nil)},
864892
},
893+
{
894+
// Correct Content-Type with parameters should pass.
895+
method: "POST",
896+
headers: http.Header{"Content-Type": {"application/json; charset=utf-8"}},
897+
messages: []jsonrpc.Message{req(5, "tools/call", &CallToolParams{Name: "tool"})},
898+
wantStatusCode: http.StatusOK,
899+
wantMessages: []jsonrpc.Message{resp(5, &CallToolResult{Content: []Content{}}, nil)},
900+
},
865901
},
866902
wantSessions: 1,
867903
},

0 commit comments

Comments
 (0)