You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
HttpServletStreamableServerTransportProvider intermittently answers -32603 "Stream unavailable for session <id>" (wrapped as JSON-RPC internal error / HTTP 500) when it attempts to push a server-initiated request (e.g. roots/list) to a client whose GET /mcp SSE stream has not been registered yet.
This is a well-documented race, and your two suggested directions are both viable, but they solve different failure windows. A few observations from running streamable HTTP servers in production:
The root issue is that session creation (POST /mcp with initialize) and server-to-client channel registration (GET /mcp SSE stream) are decoupled by design in streamable HTTP. The session exists before the GET stream does, so any capability-prompted push in that gap has nowhere to go. Treating the session as "write-ready" only after stream registration is the semantic fix; the race then disappears instead of being papered over.
Between your two options, buffering is the more robust primary mechanism: a per-session outbound queue that flushes on stream registration, with a bounded grace window (a few seconds, not unbounded) so a client that never opens the GET stream doesn't accumulate memory. A single retry on -32603 is a good secondary safety net for messages that still land in the gap, but it needs care: retrying roots/list is idempotent and safe, while retrying a notifications/ message is fine, but blind retry on a generic -32603 can double-deliver in other cases. Scoping the retry to capability-prompted request types (roots/list, resources/list_changed) is the right boundary.
One detail worth handling in the buffer: the client may register the GET stream, drop it, and re-register with the same session ID (streamable HTTP allows reconnect). The buffer should flush on every registration event, not just the first, and should not drop queued messages when the stream closes mid-flush. This is the failure mode that turns an intermittent CI flake into a production heisenbug.
For the integration test: rather than awaiting on the client side, gate the assertion on a server-side event (stream registered for session X), or use the buffered provider and assert delivery after the GET stream is established. That tests the fix instead of waiting out the race.
We hit the same seam running a multi-connector MCP gateway (corpusiq.io aggregates 36+ business APIs behind one server); per-session outbound buffering with flush-on-register plus a scoped retry for capability-prompted requests eliminated this class of flake for us. Happy to share the queue design if useful.
Unable to reproduce locally, even running the tests 1000 times. It might not be the exact problem described here. The test in question does the following:
client initializes fully
POST /initialize`
GET /mcp (opens a stream), in a fire-and-forget
POST notifications/initialized
client notify roots lists changes POST notications/roots/list_changed
Only when the server gets the notification does the server sends roots/list.
It is very unlikely that the GET is under contention for two full posts. I can reproduce by adding latency to the servlet transport's doGet, though.
The underlying issue (race condition between server notifications and GET) might be a problem under some conditions (network, client from a different ecosystem).
(There is also a separate issue at play here - the client can't really wait for the stream to be established before proceeding further, which would fix the test without fixing the race condition. But that's a whole other can of worms.)
Fresh occurrence in CI, on a branch that only touches a test file: Jackson 2 job of #1113 at 8fccf33, HttpServletStreamableIntegrationTests.testRootsSuccess(String)[1] → -32603 "Error processing message: Stream unavailable for session f79d57db-…" from HttpServletStreamableServerTransportProvider.doPost:564 (run 33192309921). Same seam as testRootsNotificationWithEmptyRootsList; the sibling Jackson 3 job passed the same class.
I can help investigate this race. The current discussion suggests the server-side contract needs to distinguish a session that exists from a stream that is not registered yet. Before proposing code, would maintainers prefer buffering pending server-to-client messages per session (with bounded lifetime) or a retryable send result? I can prepare a deterministic delayed-GET integration test once the preferred invariant is confirmed.
Looking around at what the other SDKs do, CSharp, TypeScript and Python suffer the same race condition and don't try to buffer or replay. I don't plan on changing the current behavior, especially with the new spec around the corner that has most of our focus.
I'm open to stabilizing the tests though.
The issue is with server-sent notifications, so possibly roots, elicitiation and sampling. They seem to mostly be happening in roots, so I'll add the explicit await there.
Fine with stabilizing the tests and leaving the server behavior as-is, given parity with the C#/TS/Python SDKs and the upcoming spec revision. Since #1119 does not change the runtime race, I would suggest closing this as not-planned rather than via "Fixes", so the decision stays discoverable when the 2026-07-28 transport work touches this seam.
Activity
Ben-Home commentedon Aug 28, 2026
This is a well-documented race, and your two suggested directions are both viable, but they solve different failure windows. A few observations from running streamable HTTP servers in production:
The root issue is that session creation (POST /mcp with initialize) and server-to-client channel registration (GET /mcp SSE stream) are decoupled by design in streamable HTTP. The session exists before the GET stream does, so any capability-prompted push in that gap has nowhere to go. Treating the session as "write-ready" only after stream registration is the semantic fix; the race then disappears instead of being papered over.
Between your two options, buffering is the more robust primary mechanism: a per-session outbound queue that flushes on stream registration, with a bounded grace window (a few seconds, not unbounded) so a client that never opens the GET stream doesn't accumulate memory. A single retry on -32603 is a good secondary safety net for messages that still land in the gap, but it needs care: retrying roots/list is idempotent and safe, while retrying a notifications/ message is fine, but blind retry on a generic -32603 can double-deliver in other cases. Scoping the retry to capability-prompted request types (roots/list, resources/list_changed) is the right boundary.
One detail worth handling in the buffer: the client may register the GET stream, drop it, and re-register with the same session ID (streamable HTTP allows reconnect). The buffer should flush on every registration event, not just the first, and should not drop queued messages when the stream closes mid-flush. This is the failure mode that turns an intermittent CI flake into a production heisenbug.
For the integration test: rather than awaiting on the client side, gate the assertion on a server-side event (stream registered for session X), or use the buffered provider and assert delivery after the GET stream is established. That tests the fix instead of waiting out the race.
We hit the same seam running a multi-connector MCP gateway (corpusiq.io aggregates 36+ business APIs behind one server); per-session outbound buffering with flush-on-register plus a scoped retry for capability-prompted requests eliminated this class of flake for us. Happy to share the queue design if useful.
Kehrlann commentedon Aug 28, 2026
Unable to reproduce locally, even running the tests 1000 times. It might not be the exact problem described here. The test in question does the following:
POST/initialize`GET /mcp(opens a stream), in a fire-and-forgetPOST notifications/initializedPOST notications/roots/list_changedroots/list.It is very unlikely that the
GETis under contention for two full posts. I can reproduce by adding latency to the servlet transport'sdoGet, though.The underlying issue (race condition between server notifications and
GET) might be a problem under some conditions (network, client from a different ecosystem).(There is also a separate issue at play here - the
clientcan't really wait for the stream to be established before proceeding further, which would fix the test without fixing the race condition. But that's a whole other can of worms.)Kehrlann commentedon Aug 28, 2026
Please let's discuss server-side fixes in this issue before jumping into an implementation.
slachiewicz commentedon Aug 28, 2026
Fresh occurrence in CI, on a branch that only touches a test file: Jackson 2 job of #1113 at 8fccf33,
HttpServletStreamableIntegrationTests.testRootsSuccess(String)[1]→-32603 "Error processing message: Stream unavailable for session f79d57db-…"fromHttpServletStreamableServerTransportProvider.doPost:564(run 33192309921). Same seam astestRootsNotificationWithEmptyRootsList; the sibling Jackson 3 job passed the same class.This comment was created with AI assistance.
CryoThrust commentedon Aug 31, 2026
I can help investigate this race. The current discussion suggests the server-side contract needs to distinguish a session that exists from a stream that is not registered yet. Before proposing code, would maintainers prefer buffering pending server-to-client messages per session (with bounded lifetime) or a retryable send result? I can prepare a deterministic delayed-GET integration test once the preferred invariant is confirmed.
Kehrlann commentedon Aug 31, 2026
Looking around at what the other SDKs do, CSharp, TypeScript and Python suffer the same race condition and don't try to buffer or replay. I don't plan on changing the current behavior, especially with the new spec around the corner that has most of our focus.
I'm open to stabilizing the tests though.
The issue is with server-sent notifications, so possibly
roots,elicitiationandsampling. They seem to mostly be happening inroots, so I'll add the explicit await there.slachiewicz commentedon Aug 31, 2026
Fine with stabilizing the tests and leaving the server behavior as-is, given parity with the C#/TS/Python SDKs and the upcoming spec revision. Since #1119 does not change the runtime race, I would suggest closing this as not-planned rather than via "Fixes", so the decision stays discoverable when the 2026-07-28 transport work touches this seam.
This comment was created with AI assistance.
Fix integration tests for `roots/list`
Fix integration tests for `roots/list`
Fix integration tests for `roots/list`