Describe the bug
When several sessions are subscribed to the same resource URI, Server.ResourceUpdated delivers the notification to them one at a time, and one slow session delays or fails delivery to all the others.
The delivery loop is notifySessions (mcp/shared.go:452) and notifySubscribedSessions (mcp/server.go:768):
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) // one deadline for everyone
for _, s := range sessions { // one session at a time
handleNotify(ctx, method, req)
}
Two problems follow.
1. Sessions are not isolated from each other. Session B is only written to after session A's write returns. If A is slow, B waits. If A is slow enough that the shared 10s context expires, B's handleNotify fails immediately with the deadline error and B receives nothing for this update. Since sessions comes from a map, the order is random, so a different subscriber loses out on each call.
2. The 10s deadline does not bound the slow write. On the streamable HTTP transport the write ends in stream.deliverLocked (mcp/streamable.go:1079), which calls s.w.Write(data) on the request's http.ResponseWriter directly. That call takes no context. If the client has stopped reading its SSE response and the socket buffers are full, Write blocks until the client reads again, the connection dies, or the server's WriteTimeout fires — minutes, not 10 seconds. The whole loop, and every subscriber after A, is stuck for that long.
Concrete sequence:
- Clients A and B subscribe to the same URI via
subscriptions/listen.
- A stops reading its stream but keeps the connection open. After enough notifications its socket buffers fill up.
- The next
ResourceUpdated(uri) writes to A first and blocks.
- B gets nothing — for this notification, and for every later one, until A's connection finally dies.
When the write blocks: Write returns as soon as the bytes are in the kernel send buffer, so a client that stops reading only stalls the loop once its receive buffer and the server's send buffer are both full. With small notifications that takes many writes, so the trigger is a slow-but-connected consumer or a long-lived half-open connection rather than any brief hiccup. But once it does block, nothing bounds it.
The code already anticipates this: shared.go:458 says // TODO: make this timeout configurable, or call handleNotify asynchronously.
Observed on v1.7.0; the code paths are unchanged on current main.
Why it matters
A server keeping subscriptions/listen streams alive through an idle-timeout proxy has to write something to each stream periodically. The 2026-07-28 Streamable HTTP binding recommends a periodic SSE comment for exactly this, but the SDK has no way to emit one (#1229); ping is removed from the protocol in that revision, and stateless servers cannot make requests anyway (streamable.go:1796). So the only per-stream write a server author has today is ResourceUpdated. Under the behaviour above, one unresponsive client on a URI silences that keepalive for every other client on the same URI until a proxy closes their streams as well. #1229 would remove that particular need, but the serial delivery affects real change notifications just the same.
Expected behavior
One session's slow write should affect only that session. For example, notifySessions / notifySubscribedSessions could start one goroutine per session, each with its own deadline, and wait for all of them (keeping today's "returns after attempting every session" behaviour). Separately, the stream write could honour the context, or deliverLocked could be arranged so a blocked write on one stream cannot hold up writes to other streams.
A per-session notification API (#745 / #1146) would also let server authors work around this by notifying each subscriber with its own context.
To Reproduce
Two clients subscribed to one URI. Make the first stop reading its response (a raw subscriptions/listen POST whose body is never read), call ResourceUpdated on that URI until its socket buffers are full, and measure when the second, healthy client stops receiving notifications.
Version
go-sdk v1.7.0 (mcp/shared.go:452-470, mcp/server.go:764-782 and :1114-1140, mcp/streamable.go:1079-1130).
Describe the bug
When several sessions are subscribed to the same resource URI,
Server.ResourceUpdateddelivers the notification to them one at a time, and one slow session delays or fails delivery to all the others.The delivery loop is
notifySessions(mcp/shared.go:452) andnotifySubscribedSessions(mcp/server.go:768):Two problems follow.
1. Sessions are not isolated from each other. Session B is only written to after session A's write returns. If A is slow, B waits. If A is slow enough that the shared 10s context expires, B's
handleNotifyfails immediately with the deadline error and B receives nothing for this update. Sincesessionscomes from a map, the order is random, so a different subscriber loses out on each call.2. The 10s deadline does not bound the slow write. On the streamable HTTP transport the write ends in
stream.deliverLocked(mcp/streamable.go:1079), which callss.w.Write(data)on the request'shttp.ResponseWriterdirectly. That call takes no context. If the client has stopped reading its SSE response and the socket buffers are full,Writeblocks until the client reads again, the connection dies, or the server'sWriteTimeoutfires — minutes, not 10 seconds. The whole loop, and every subscriber after A, is stuck for that long.Concrete sequence:
subscriptions/listen.ResourceUpdated(uri)writes to A first and blocks.When the write blocks:
Writereturns as soon as the bytes are in the kernel send buffer, so a client that stops reading only stalls the loop once its receive buffer and the server's send buffer are both full. With small notifications that takes many writes, so the trigger is a slow-but-connected consumer or a long-lived half-open connection rather than any brief hiccup. But once it does block, nothing bounds it.The code already anticipates this:
shared.go:458says// TODO: make this timeout configurable, or call handleNotify asynchronously.Observed on v1.7.0; the code paths are unchanged on current
main.Why it matters
A server keeping
subscriptions/listenstreams alive through an idle-timeout proxy has to write something to each stream periodically. The 2026-07-28 Streamable HTTP binding recommends a periodic SSE comment for exactly this, but the SDK has no way to emit one (#1229);pingis removed from the protocol in that revision, and stateless servers cannot make requests anyway (streamable.go:1796). So the only per-stream write a server author has today isResourceUpdated. Under the behaviour above, one unresponsive client on a URI silences that keepalive for every other client on the same URI until a proxy closes their streams as well. #1229 would remove that particular need, but the serial delivery affects real change notifications just the same.Expected behavior
One session's slow write should affect only that session. For example,
notifySessions/notifySubscribedSessionscould start one goroutine per session, each with its own deadline, and wait for all of them (keeping today's "returns after attempting every session" behaviour). Separately, the stream write could honour the context, ordeliverLockedcould be arranged so a blocked write on one stream cannot hold up writes to other streams.A per-session notification API (#745 / #1146) would also let server authors work around this by notifying each subscriber with its own context.
To Reproduce
Two clients subscribed to one URI. Make the first stop reading its response (a raw
subscriptions/listenPOST whose body is never read), callResourceUpdatedon that URI until its socket buffers are full, and measure when the second, healthy client stops receiving notifications.Version
go-sdk v1.7.0 (
mcp/shared.go:452-470,mcp/server.go:764-782and:1114-1140,mcp/streamable.go:1079-1130).