Repository navigation
Description
Summary
A @McpTool method whose return type is a bare Mono<T> (not Mono<Void>,
not Mono<CallToolResult>) that completes empty — no onNext, just
onComplete — causes the calling MCP client to hang indefinitely. The
server never sends any JSON-RPC response for that request: not a result,
not an error, nothing. This violates the JSON-RPC 2.0 contract that every
Request with an id receives exactly one Response.
Versions
- spring-ai-starter-mcp-server-webflux 1.1.7
- org.springaicommunity:mcp-annotations 0.9.0
- io.modelcontextprotocol.sdk:mcp-core 0.18.2
Also reproduced against mcp-annotations built on MCP Java SDK 2.0.0 (Spring
AI 2.0.0-line) — the SDK-side code below is unchanged there, so I'd expect
this to still reproduce on current main.
Root cause (two independent layers drop the same signal)
1. mcp-annotations,
AbstractAsyncMcpToolMethodCallback.convertToCallToolResult:
// Handle other Mono types - map the emitted value to CallToolResult
return monoResult.map(this::mapValueToCallToolResult)
.onErrorResume(e -> Mono.just(CallToolResult.builder()
.isError(true)
.addTextContent("Error invoking method: %s".formatted(e.getMessage()))
.build()));.map() never runs on an empty source, and there's no switchIfEmpty(...),
so the resulting Mono<CallToolResult> is also empty.
2. MCP Java SDK, McpServerSession.handleIncomingRequest — same shape,
independently:
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 -> { /* ... */ });Again .map() on an empty source produces nothing. That empty
Mono<JSONRPCResponse> then reaches McpServerSession.handle:
return handleIncomingRequest(request, transportContext)
.onErrorResume(error -> { ... })
.flatMap(this.transport::sendMessage);.flatMap never invokes sendMessage for an empty source. So
transport.sendMessage is never called at all for that request — no
bytes go out over the wire, ever. The client's pending request just sits
unanswered until (if) it has its own client-side timeout.
Why this is easy to hit legitimately
Any tool backed by a reactive repository/service that models "not found" as
an empty Mono (a very common Reactor idiom — e.g.
Mono<T> get(id) completing empty when nothing matches) will trigger this
silently. There's no compiler warning and no runtime exception — the
request just never comes back.
Reproduction
@McpTool(name = "empty", description = "completes without emitting a value")
public Mono<String> empty() {
return Mono.empty();
}Call empty from any MCP client and observe: the client never receives a
response (verified with .block() on the raw callHandler().apply(...)
Mono in a unit test — it returns null immediately in-process, which
confirms the value is empty; against a real transport that only acts on
onNext, this becomes an indefinite hang instead of a null).
Suggested fix
AbstractAsyncMcpToolMethodCallback already special-cases Mono<Void> by
synthesizing a success result ("Done") when the tool declares that return
type. The same reasoning should extend to a bare Mono<T> that completes
empty at runtime — add a switchIfEmpty(...) there (and/or defensively in
McpServerSession.handleIncomingRequest) that converts it into an explicit
isError: true CallToolResult (or a defined empty-success shape) rather
than letting the empty completion propagate silently. The framework should
never allow transport.sendMessage to go uncalled for a request that has
an id.
Longer-term, it might be worth a first-class way for tool authors to
declare "empty is a legitimate answer" (e.g. Mono<Optional<T>>) versus an
unexpectedly-empty Mono<T> being treated as a framework-reportable fault
— mirroring how Mono<Void> is already handled explicitly today.
Workaround
We worked around this at the application layer by never returning a
Mono<T> that can complete empty from a @McpTool method — converting the
empty case to an explicit thrown/mapped exception instead, which does
propagate correctly (the .onErrorResume branches in both layers above
already work fine — only the empty-completion path is broken).
Activity