McpServerSession never sends a response when a request handler's Mono completes empty — violates JSON-RPC 2.0's one-response-per-request contract

未關閉
#1,081 2 則留言 0 個 reaction 已指派 0 人 在 GitHub 檢視

還沒有人認領這個 Issue。

評估

難度
4/5
預估耗時
3-5 天
新手友好度
68/100
Issue 類型
缺陷
描述清晰度
描述清楚
活躍度
冷清
技術堆疊
java

研究方向

從 McpServerSession.handleIncomingRequest 和 handle 開始,接著檢查 McpStatelessAsyncServer 中相應的請求處理路徑。追蹤一個空的 Mono,從自訂 McpRequestHandler 經過回應建立和 transport::sendMessage,然後使用所描述的 Empty-Mono 重現進行驗證,確保每個請求恰好產生一個 JSON-RPC 回應。

由索引模型根據 Issue 內容生成。

描述

bug P2 ready for work
Summary

MCP rides on JSON-RPC 2.0, which requires that every Request carrying an
id receives exactly one Response (a result or an error). In
McpServerSession, if the Mono<T> returned by a registered
McpRequestHandler completes empty — no onNext, just onComplete
the session never sends any response at all for that request. Not a
result, not an error: nothing. The client is left waiting indefinitely.

This is a protocol-conformance gap in the SDK's own dispatch code, independent
of any specific request handler's correctness — any handler that can
legitimately or accidentally produce an empty Mono breaks the contract
for its caller, silently.

Filed alongside spring-ai-community/mcp-annotations#113
(https://github.com/spring-ai-community/mcp-annotations/issues/113), which
documents the concrete case that surfaced this: a @McpTool method
returning a bare Mono<T> that completes empty (e.g. a reactive
repository's get(id) completing empty when nothing matches — a very
common Reactor idiom). That issue is about the annotation-callback layer
converting a tool's empty Mono<T> into an empty Mono<CallToolResult>.
This issue is about the fact that this SDK's own session layer has the
identical gap, so even if the annotation layer is fixed, any other
McpRequestHandler implementation (custom or third-party, not just
tools/call) can trigger the same silent hang here.

Versions

Reproduced in:

  • io.modelcontextprotocol.sdk:mcp-core 0.18.2
  • io.modelcontextprotocol.sdk:mcp-core 2.0.0 (the relevant code is
    unchanged between these two versions, so this likely affects main too)
Root cause

McpServerSession.handleIncomingRequest:

resultMono = this.exchangeSink.asMono()
    .flatMap(exchange -> handler.handle(copyExchange(exchange, transportContext), request.params()));

return resultMono
    .map(result -> new McpSchema.JSONRPCResponse(McpSchema.JSONRPC_VERSION, request.id(), result, null))
    .onErrorResume(error -> {
        // ... builds and returns an error JSONRPCResponse
    });

.map() only runs on emission. If handler.handle(...) returns a Mono<T>
that completes empty, this method's own Mono<JSONRPCResponse> is also
empty — no exception, no error branch taken, just silently empty.

McpServerSession.handle:

else if (message instanceof McpSchema.JSONRPCRequest request) {
    return handleIncomingRequest(request, transportContext).onErrorResume(error -> {
        // ... sends an error response
    }).flatMap(this.transport::sendMessage);
}

.flatMap never invokes its function for an empty source, so
this.transport.sendMessage(...) is never called for this request. No
bytes go out over the wire for that request, ever. The client's pending
call just sits unanswered until (if) it enforces its own timeout.

Same shape appears in McpStatelessAsyncServer's equivalent request
handling path.

Reproduction

Any custom McpRequestHandler<T> (registered via
McpAsyncServer/McpStatelessAsyncServer) whose handle(...) method
returns a Mono<T> that can complete empty will reproduce this — it does
not require going through the annotation-based tool support. Concretely,
the annotation-callback path in mcp-annotations#113 hits it via a
@McpTool method returning Mono.empty().

Suggested fix

handleIncomingRequest (and the stateless equivalent) should guarantee
resultMono never reaches the final .map()/.onErrorResume() chain in
an empty state — e.g. a switchIfEmpty(...) that converts an unexpectedly
empty result into an explicit JSON-RPC error response
(McpSchema.ErrorCodes.INTERNAL_ERROR, or a dedicated code). This would
make the guarantee "every request gets exactly one response" hold at the
SDK level regardless of what any individual McpRequestHandler
implementation does — the same principle JSON-RPC 2.0 itself requires.

Workaround

At the application layer, we avoid returning a Mono<T> that can complete
empty from any MCP-registered handler — converting the empty case to an
explicit error/exception instead, since the .onErrorResume branches in
both this method and .handle(...) already work correctly; only the
empty-completion path is broken.

主要語言
Java
星號
3.7k
分支
1.1k
平均合併
1 天 15 小時
30 天內合併 PR
9

貢獻指南

開啟貢獻指南

從這裡開始

  1. 先讀完整個 Issue,再讀專案的貢獻指南。
  2. 在 Issue 下留言說明你要接手 —— 這能避免兩個人做同樣的事。
  3. Fork 儲存庫,在一個分支上完成修改。
  4. 送出 Pull Request,並在描述裡引用這個 Issue 編號。

modelcontextprotocol/java-sdk 的其他 Issue

查看 modelcontextprotocol/java-sdk 的全部 Issue

相似的 Issue

更多 Java Issue

把新 issue 寄到你的電子郵件信箱

精選適合新手參與的 GitHub issue 摘要。