Skip to content

A Mono<T> tool method that completes empty hangs the client forever — no CallToolResult is ever sent #113

Open

Description

@ChiragMoradiya

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions