Stateful streamable server calls listRoots() unconditionally on roots/list_changed, blocking a servlet thread until requestTimeout when no roots consumer is registered
Bug description
When a McpAsyncServer / McpSyncServer uses the streamable HTTP transport
(HttpServletStreamableServerTransportProvider) and a client that advertises
capabilities.roots.listChanged sends a notifications/roots/list_changed
message, the server issues a server→client roots/list request back to the
client — even when the application registered no rootsChangeConsumer.
The transport handles the incoming notification by blocking the servlet
(request) thread on that round-trip. If the client does not answer roots/list
(many don't — it's a fire-and-forget notification from their side), the thread
stays parked for the full requestTimeout and then fails with a
TimeoutException. Every such notification pins one HTTP worker thread for the
whole timeout window; under multiple clients this degrades the server's request
pool.
This is the stateful counterpart of the already-merged stateless fix in
#835. #835 made roots/list_changed a no-op for
HttpServletStatelessServerTransport; the stateful streamable path
(McpAsyncServer.asyncRootsListChangedNotificationHandler) was not touched and
still performs the unconditional round-trip — and here it doesn't just log a
warning, it blocks a thread.
Environment
io.modelcontextprotocol.sdk:mcp-core 2.0.0 (also present on main)
- Transport: Streamable HTTP,
HttpServletStreamableServerTransportProvider
- Server:
McpServer.sync(...), servlet container (Jetty / Tomcat)
- Client: any client that declares
roots.listChanged and sends the
notification without answering the resulting roots/list (e.g. Claude Desktop
via mcp-remote)
Root cause
1. The notification handler calls listRoots() unconditionally.
McpAsyncServer.prepareNotificationHandlers substitutes a logging consumer when
none is registered (mcp-core 2.0.0, lines 199–201):
if (Utils.isEmpty(rootsChangeConsumers)) {
rootsChangeConsumers = List.of((exchange, roots) -> Mono.fromRunnable(() -> logger
.warn("Roots list changed notification, but no consumers provided. Roots list changed: {}", roots)));
}
and asyncRootsListChangedNotificationHandler then calls
exchange.listRoots() regardless (line 317):
return (exchange, params) -> exchange.listRoots() // <-- always sent, even to log-and-drop
.flatMap(listRootsResult -> Flux.fromIterable(rootsChangeConsumers)
.flatMap(consumer -> Mono.defer(() -> consumer.apply(exchange, listRootsResult.roots())))
...
So a roots/list request goes out to the client to fetch a value that is only
logged.
2. The streamable transport blocks the servlet thread on the notification.
HttpServletStreamableServerTransportProvider.doPost handles a notification by
.block()-ing (mcp-core 2.0.0, line 505):
else if (message instanceof McpSchema.JSONRPCNotification jsonrpcNotification) {
session.accept(jsonrpcNotification)
.contextWrite(ctx -> ctx.put(McpTransportContext.KEY, transportContext))
.block(); // <-- request thread parks here
response.setStatus(HttpServletResponse.SC_ACCEPTED);
}
3. The server→client request times out after requestTimeout.
McpStreamableServerSession$McpStreamableServerSessionStream.sendRequest
(lines 404–412):
return Mono.<McpSchema.JSONRPCResponse>create(sink -> {
this.pendingResponses.put(requestId, sink);
...
this.transport.sendMessage(jsonrpcRequest, messageId).subscribe(v -> {}, sink::error);
}).timeout(requestTimeout) // <-- no answer → TimeoutException
Because McpServer.sync(...) defaults requestTimeout to 10s (line 945) — and
higher if the app raised it — the thread is pinned for that entire window and
then logs:
ERROR ... HttpServletStreamableServerTransportProvider: Error handling message:
java.util.concurrent.TimeoutException: Did not observe any item or terminal
signal within 10000ms in 'source(MonoCreate)'
Steps to reproduce
A stateful streamable McpServer.sync(...) on
HttpServletStreamableServerTransportProvider with no rootsChangeConsumer
registered. Endpoint is /mcp below.
# 1. initialize, declaring roots.listChanged — capture the mcp-session-id from response headers
curl -sS -D - -o /dev/null -X POST http://localhost:8080/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","id":0,"method":"initialize",
"params":{"protocolVersion":"2025-11-25",
"capabilities":{"roots":{"listChanged":true}},
"clientInfo":{"name":"roots-repro","version":"1.0.0"}}}'
SID=<mcp-session-id from above>
# 2. complete the handshake
curl -sS -X POST http://localhost:8080/mcp \
-H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' \
-H "mcp-session-id: $SID" \
-d '{"jsonrpc":"2.0","method":"notifications/initialized"}'
# 3. open the listening GET SSE stream (REQUIRED — without it the server fails fast
# with "Stream unavailable for session" instead of stalling)
curl -sS -N http://localhost:8080/mcp -H 'Accept: text/event-stream' \
-H "mcp-session-id: $SID" &
# 4. the trigger — this POST hangs for the full requestTimeout, then returns 500
time curl -sS -X POST http://localhost:8080/mcp \
-H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' \
-H "mcp-session-id: $SID" \
-d '{"jsonrpc":"2.0","method":"notifications/roots/list_changed"}'
On the listening stream from step 3 you will see the server push a
{"method":"roots/list",...} request; nobody answers it, and step 4 blocks for
requestTimeout seconds.
Expected behavior
notifications/roots/list_changed should be acknowledged (202) promptly. When
no rootsChangeConsumer is registered, the server should not send a
roots/list request at all — there is nothing to deliver the result to. This
matches the stateless behavior already merged in #835.
Actual behavior
The server sends roots/list, the servlet thread blocks on it, and after
requestTimeout it fails with the MonoCreate TimeoutException above. One
HTTP worker thread is consumed per notification for the full timeout.
Impact
- One pinned HTTP worker thread per
roots/list_changed, for up to
requestTimeout; concurrent clients multiply it and degrade the request pool.
- Recurring
ERROR-level log noise.
- Affects every client that declares
roots.listChanged and doesn't answer the
unsolicited roots/list — which is spec-legal client behavior.
Proposed fix
Skip the round-trip when there is nothing to consume — the stateful analog of
#835:
- In
asyncRootsListChangedNotificationHandler (or
prepareNotificationHandlers), only call exchange.listRoots() when a real
rootsChangeConsumer was registered; otherwise treat the notification as a
no-op. Don't substitute a logging consumer that forces a client round-trip
purely to log the result.
This also aligns with the direction of #1003 (SEP-2260, "require server requests
to be associated with a client request") and #1012 / #1053 (deprecate roots),
where an unsolicited server→client roots/list triggered by a notification is
being designed out of the spec.
Related
Workaround
A servlet filter in front of the MCP endpoint that answers
notifications/roots/list_changed with 202 Accepted and drops it, so it never
reaches the SDK handler.
Stateful streamable server calls
listRoots()unconditionally onroots/list_changed, blocking a servlet thread untilrequestTimeoutwhen no roots consumer is registeredBug description
When a
McpAsyncServer/McpSyncServeruses the streamable HTTP transport(
HttpServletStreamableServerTransportProvider) and a client that advertisescapabilities.roots.listChangedsends anotifications/roots/list_changedmessage, the server issues a server→client
roots/listrequest back to theclient — even when the application registered no
rootsChangeConsumer.The transport handles the incoming notification by blocking the servlet
(request) thread on that round-trip. If the client does not answer
roots/list(many don't — it's a fire-and-forget notification from their side), the thread
stays parked for the full
requestTimeoutand then fails with aTimeoutException. Every such notification pins one HTTP worker thread for thewhole timeout window; under multiple clients this degrades the server's request
pool.
This is the stateful counterpart of the already-merged stateless fix in
#835. #835 made
roots/list_changeda no-op forHttpServletStatelessServerTransport; the stateful streamable path(
McpAsyncServer.asyncRootsListChangedNotificationHandler) was not touched andstill performs the unconditional round-trip — and here it doesn't just log a
warning, it blocks a thread.
Environment
io.modelcontextprotocol.sdk:mcp-core2.0.0 (also present onmain)HttpServletStreamableServerTransportProviderMcpServer.sync(...), servlet container (Jetty / Tomcat)roots.listChangedand sends thenotification without answering the resulting
roots/list(e.g. Claude Desktopvia
mcp-remote)Root cause
1. The notification handler calls
listRoots()unconditionally.McpAsyncServer.prepareNotificationHandlerssubstitutes a logging consumer whennone is registered (mcp-core 2.0.0, lines 199–201):
and
asyncRootsListChangedNotificationHandlerthen callsexchange.listRoots()regardless (line 317):So a
roots/listrequest goes out to the client to fetch a value that is onlylogged.
2. The streamable transport blocks the servlet thread on the notification.
HttpServletStreamableServerTransportProvider.doPosthandles a notification by.block()-ing (mcp-core 2.0.0, line 505):3. The server→client request times out after
requestTimeout.McpStreamableServerSession$McpStreamableServerSessionStream.sendRequest(lines 404–412):
Because
McpServer.sync(...)defaultsrequestTimeoutto 10s (line 945) — andhigher if the app raised it — the thread is pinned for that entire window and
then logs:
Steps to reproduce
A stateful streamable
McpServer.sync(...)onHttpServletStreamableServerTransportProviderwith norootsChangeConsumerregistered. Endpoint is
/mcpbelow.On the listening stream from step 3 you will see the server push a
{"method":"roots/list",...}request; nobody answers it, and step 4 blocks forrequestTimeoutseconds.Expected behavior
notifications/roots/list_changedshould be acknowledged (202) promptly. Whenno
rootsChangeConsumeris registered, the server should not send aroots/listrequest at all — there is nothing to deliver the result to. Thismatches the stateless behavior already merged in #835.
Actual behavior
The server sends
roots/list, the servlet thread blocks on it, and afterrequestTimeoutit fails with theMonoCreateTimeoutExceptionabove. OneHTTP worker thread is consumed per notification for the full timeout.
Impact
roots/list_changed, for up torequestTimeout; concurrent clients multiply it and degrade the request pool.ERROR-level log noise.roots.listChangedand doesn't answer theunsolicited
roots/list— which is spec-legal client behavior.Proposed fix
Skip the round-trip when there is nothing to consume — the stateful analog of
#835:
asyncRootsListChangedNotificationHandler(orprepareNotificationHandlers), only callexchange.listRoots()when a realrootsChangeConsumerwas registered; otherwise treat the notification as ano-op. Don't substitute a logging consumer that forces a client round-trip
purely to log the result.
This also aligns with the direction of #1003 (SEP-2260, "require server requests
to be associated with a client request") and #1012 / #1053 (deprecate roots),
where an unsolicited server→client
roots/listtriggered by a notification isbeing designed out of the spec.
Related
(
roots/list_changed→ no-op). This issue asks to extend that to thestateful streamable path. Its parent Missing handler for request type: notifications/initialized #777 is the stateless "Missing
handler" symptom.
unsolicited server→client roots requests. Directional, not a current-SDK fix.
(session eviction on write failure; CLOSE-WAIT thread exhaustion). Distinct
root causes, mentioned only to disambiguate.
Workaround
A servlet filter in front of the MCP endpoint that answers
notifications/roots/list_changedwith202 Acceptedand drops it, so it neverreaches the SDK handler.