Skip to content

Commit 29f3fe2

Browse files
committed
mcp: notify sessions concurrently so one stalled peer cannot starve the rest
notifySessions and Server.notifySubscribedSessions delivered a broadcast to its subscribers one session at a time, all under a single shared 10s context. A session whose write did not return promptly held up every session after it, and once the shared context expired the remaining sessions failed with the deadline error without ever being attempted. Because the streamable transport's stream write is a plain http.ResponseWriter write that does not observe the context, a peer that had stopped reading could hold the loop far longer than 10s. Send to each session on its own goroutine with its own deadline, and wait for all attempts before returning, preserving the existing "returns after attempting every session" behaviour. A stalled peer now delays or fails only its own delivery. The test stalls one in-memory peer by never reading its end of the pipe and checks that a second, healthy session still receives the notification while the first is blocked. It fails against the serial implementation. Fixes #1227
1 parent 0d3036f commit 29f3fe2

3 files changed

Lines changed: 111 additions & 19 deletions

File tree

mcp/server.go

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -811,16 +811,22 @@ func (s *Server) notifySubscribedSessions(subscribers map[*ServerSession]jsonrpc
811811
if len(subscribers) == 0 {
812812
return
813813
}
814-
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
815-
defer cancel()
814+
// Concurrently, each under its own deadline: see notifySessions.
815+
var wg sync.WaitGroup
816816
for sess, reqID := range subscribers {
817-
params := makeParams()
818-
injectMetaSubscriptionID(params, reqID)
819-
req := newRequest(sess, params)
820-
if err := handleNotify(ctx, method, req); err != nil {
821-
s.opts.Logger.Warn(fmt.Sprintf("calling %s: %v", method, err))
822-
}
817+
wg.Add(1)
818+
go func() {
819+
defer wg.Done()
820+
ctx, cancel := context.WithTimeout(context.Background(), notifyTimeout)
821+
defer cancel()
822+
params := makeParams()
823+
injectMetaSubscriptionID(params, reqID)
824+
if err := handleNotify(ctx, method, newRequest(sess, params)); err != nil {
825+
s.opts.Logger.Warn(fmt.Sprintf("calling %s: %v", method, err))
826+
}
827+
}()
823828
}
829+
wg.Wait()
824830
}
825831

826832
// injectMetaSubscriptionID stamps the listen request's JSON-RPC ID into the

mcp/server_test.go

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1932,3 +1932,76 @@ func TestServerSupportedProtocolVersions_NewProtocol(t *testing.T) {
19321932
t.Errorf("UnsupportedProtocolVersionData.Supported mismatch (-want +got):\n%s", diff)
19331933
}
19341934
}
1935+
1936+
// TestNotifySessionsIsolatesStalledPeer verifies that a session whose write
1937+
// stalls — here a peer that never reads its end of the pipe — does not delay
1938+
// or fail delivery to the other sessions in the same broadcast.
1939+
func TestNotifySessionsIsolatesStalledPeer(t *testing.T) {
1940+
ctx := context.Background()
1941+
server := NewServer(testImpl, nil)
1942+
1943+
// The stalled session: nothing reads the client end until the end of the
1944+
// test, so the server's first write blocks (net.Pipe is synchronous).
1945+
stalledCT, stalledST := NewInMemoryTransports()
1946+
stalled, err := server.Connect(ctx, stalledST, nil)
1947+
if err != nil {
1948+
t.Fatal(err)
1949+
}
1950+
1951+
// The healthy session: a real client that records the notification.
1952+
got := make(chan string, 1)
1953+
healthyCT, healthyST := NewInMemoryTransports()
1954+
healthy, err := server.Connect(ctx, healthyST, nil)
1955+
if err != nil {
1956+
t.Fatal(err)
1957+
}
1958+
client := NewClient(testImpl, &ClientOptions{
1959+
ResourceUpdatedHandler: func(_ context.Context, req *ResourceUpdatedNotificationRequest) {
1960+
select {
1961+
case got <- req.Params.URI:
1962+
default:
1963+
}
1964+
},
1965+
})
1966+
cs, err := client.Connect(ctx, healthyCT, nil)
1967+
if err != nil {
1968+
t.Fatal(err)
1969+
}
1970+
defer cs.Close()
1971+
1972+
// Stalled first: a serial implementation would sit on it and never reach
1973+
// the healthy session.
1974+
done := make(chan struct{})
1975+
go func() {
1976+
defer close(done)
1977+
notifySessions([]*ServerSession{stalled, healthy}, notificationResourceUpdated,
1978+
&ResourceUpdatedNotificationParams{URI: "test://stalled-peer"}, slog.Default())
1979+
}()
1980+
1981+
select {
1982+
case uri := <-got:
1983+
if uri != "test://stalled-peer" {
1984+
t.Fatalf("got notification for %q", uri)
1985+
}
1986+
case <-time.After(5 * time.Second):
1987+
t.Fatal("healthy session was not notified while another session's write was stalled")
1988+
}
1989+
1990+
// Draining the stalled peer releases its write and lets the broadcast
1991+
// complete. (Session.Close cannot do this: it waits for in-flight writes
1992+
// before closing the underlying connection.)
1993+
stalledConn, err := stalledCT.Connect(ctx)
1994+
if err != nil {
1995+
t.Fatal(err)
1996+
}
1997+
if _, err := stalledConn.Read(ctx); err != nil {
1998+
t.Fatal(err)
1999+
}
2000+
select {
2001+
case <-done:
2002+
case <-time.After(5 * time.Second):
2003+
t.Fatal("notifySessions did not return after the stalled peer read its message")
2004+
}
2005+
stalled.Close()
2006+
stalledConn.Close()
2007+
}

mcp/shared.go

Lines changed: 24 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import (
2121
"reflect"
2222
"slices"
2323
"strings"
24+
"sync"
2425
"time"
2526

2627
"github.com/modelcontextprotocol/go-sdk/auth"
@@ -466,27 +467,39 @@ const (
466467
codeUnsupportedMethod = -31001
467468
)
468469

469-
// notifySessions calls Notify on all the sessions.
470+
// notifyTimeout bounds each session's notification send in notifySessions
471+
// and Server.notifySubscribedSessions.
472+
//
473+
// TODO: make this configurable.
474+
const notifyTimeout = 10 * time.Second
475+
476+
// notifySessions calls Notify on all the sessions, concurrently and each
477+
// under its own deadline, so that one session whose write stalls (a peer that
478+
// has stopped reading) neither delays nor fails delivery to the others. It
479+
// returns once every session has been attempted.
470480
// Should be called on a copy of the peer sessions.
471481
// The logger must be non-nil.
472482
func notifySessions[S Session, P Params](sessions []S, method string, params P, logger *slog.Logger) {
473483
if sessions == nil {
474484
return
475485
}
476-
// Notify with the background context, so the messages are sent on the
477-
// standalone stream.
478-
// TODO: make this timeout configurable, or call handleNotify asynchronously.
479-
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
480-
defer cancel()
481-
482486
// TODO: there's a potential spec violation here, when the feature list
483487
// changes before the session (client or server) is initialized.
488+
var wg sync.WaitGroup
484489
for _, s := range sessions {
485-
req := newRequest(s, params)
486-
if err := handleNotify(ctx, method, req); err != nil {
487-
logger.Warn(fmt.Sprintf("calling %s: %v", method, err))
488-
}
490+
wg.Add(1)
491+
go func() {
492+
defer wg.Done()
493+
// Notify with the background context, so the messages are sent on
494+
// the standalone stream.
495+
ctx, cancel := context.WithTimeout(context.Background(), notifyTimeout)
496+
defer cancel()
497+
if err := handleNotify(ctx, method, newRequest(s, params)); err != nil {
498+
logger.Warn(fmt.Sprintf("calling %s: %v", method, err))
499+
}
500+
}()
489501
}
502+
wg.Wait()
490503
}
491504

492505
func newRequest[S Session, P Params](s S, p P) Request {

0 commit comments

Comments
 (0)