modelcontextprotocol / modelcontextprotocol/java-sdk

Stateful streamable server calls listRoots() unconditionally on roots/list_changed, blocking a servlet thread until requestTimeout when no roots consumer is registered

Đang mở
#1,061 2 bình luận 1 reaction 0 người được giao Xem trên GitHub

Chưa có ai nhận issue này.

bug P2 ready for work
Ngôn ngữ chính
Java
Star
3.7k
Fork
1.1k
Merge trung bình
1 ngày 15 giờ
Pull request đã merge (30 ngày)
9

Mô tả

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

  • #835 (merged) — same fix for the stateless transport
    (roots/list_changed → no-op). This issue asks to extend that to the
    stateful streamable path. Its parent #777 is the stateless "Missing
    handler" symptom.
  • #1003 (SEP-2260) and #1012 / #1053 — spec work removing/deprecating
    unsolicited server→client roots requests. Directional, not a current-SDK fix.
  • #920 and #1021 — separate streamable-transport robustness issues
    (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_changed with 202 Accepted and drops it, so it never
reaches the SDK handler.

Hướng dẫn đóng góp

Mở hướng dẫn đóng góp

Bắt đầu từ đâu

  1. Đọc hết issue, rồi đọc hướng dẫn đóng góp của dự án.
  2. Bình luận trên issue rằng bạn sẽ nhận — tránh hai người làm cùng một việc.
  3. Fork repository và làm thay đổi trên một nhánh.
  4. Mở pull request có tham chiếu số hiệu của issue.

Hướng nghiên cứu

Bắt đầu với McpAsyncServer.asyncRootsListChangedNotificationHandler và prepareNotificationHandlers, sau đó kiểm tra HttpServletStreamableServerTransportProvider.doPost và đường dẫn yêu cầu của phiên stream có trạng thái. Tái hiện vấn đề bằng chuỗi curl được cung cấp và xác minh rằng, khi không có rootsChangeConsumer, không có yêu cầu roots/list nào được gửi và thông báo trả về 202 ngay lập tức.

Do mô hình lập chỉ mục viết ra từ nội dung của issue.

Đánh giá

Công nghệ
java
Lĩnh vực
api, backend
Loại issue
Lỗi
Độ khó
3/5
Thời gian dự kiến
1-2 ngày
Mức độ hoạt động
Sôi nổi
Độ rõ ràng
Đặc tả rõ ràng
Mức phù hợp với người mới
78/100

Nhận issue mới trong hộp thư của bạn

Bản tóm tắt ngắn những issue GitHub phù hợp với người mới.