When a server-side request handler returns a Mono that completes empty, the SDK previously sent no response at all for that request, leaving the client waiting indefinitely. This violates JSON-RPC 2.0's one-response-per-request contract.
Motivation and Context
MCP rides on JSON-RPC 2.0, which requires that every request carrying an id receives exactly one response (a result or an error). Two server-side dispatch paths had the gap:
McpServerSession.handleIncomingRequest (stateful servers): the empty Mono flowed through .map(...) (no emission) and .onErrorResume(...) (no error), so handle()'s .flatMap(this.transport::sendMessage) was never invoked and no bytes went over the wire.
DefaultMcpStatelessServerHandler.handleRequest (stateless servers): same empty propagation; callers doing .block() received null instead of a JSON-RPC response.
An empty completion is a common Reactor idiom (e.g. a reactive repository's get(id) completing empty when nothing matches), so this is a realistic failure mode, not a theoretical one. Fixes #1081.
Both paths now append .switchIfEmpty(...): an empty completion produces exactly one JSON-RPC error response (-32603 Internal error) naming the method, plus a WARN log. Normal result and error behavior is unchanged — switchIfEmpty only fires on empty completion.
How Has This Been Tested?
- Added regression tests covering all three paths in both dispatch layers:
McpServerSessionTests (new): empty completion → exactly one error response with matching id; normal result → result response; failing handler → error response.
DefaultMcpStatelessServerHandlerTests (extended): empty handler result → INTERNAL_ERROR response; failing handler → error response.
- Verified locally with
mvn -pl mcp-core test — 6/6 new tests pass, module builds clean with spring-javaformat validation.
- Full-suite integration tests require Docker and are covered by CI on GitHub.
Breaking Changes
None. This is an additive fix for a previously-broken path (empty handler completion previously produced no response; it now produces an error response). Existing successful/error behavior is byte-for-byte unchanged.
Types of changes
- Bug fix (non-breaking change which fixes an issue)
- New feature (non-breaking change which adds functionality)
- Breaking change (fix or feature that would cause existing functionality to change)
- Documentation update
Checklist
- I have read the MCP Documentation
- My code follows the repository's style guidelines
- New and existing tests pass locally
- I have added appropriate error handling
- I have added or updated documentation as needed
Additional context
Root cause: Mono.map() does not emit for an empty source and onErrorResume does not trigger without an error, so the empty completion propagated silently through both dispatch chains. The fix converts that silent empty completion into a contract-compliant error response.