spring-projects/spring-ai · error · IllegalStateException
Expected reactive return type but got: {resultClassName|null
Error message
Expected reactive return type but got: {resultClassName|null} What it means
AbstractAsyncMcpToolMethodCallback.convertToCallToolResult throws IllegalStateException when the tool method's result is neither a Mono nor another reactive Publisher in an async callback — described in code as a fallback that 'should not happen in async context'. It means the async tool callback received a non-reactive result, indicating the method's declared return type and actual execution path disagree.
Source
Thrown at mcp/mcp-annotations/src/main/java/org/springframework/ai/mcp/annotation/method/tool/AbstractAsyncMcpToolMethodCallback.java:144
// Check if the Publisher contains CallToolResult
if (ReactiveUtils.isReactiveReturnTypeOfCallToolResult(this.toolMethod)) {
return ((Mono<CallToolResult>) monoFromPublisher).onErrorResume(this::toErrorResultOrPropagate);
}
// Handle Mono<Void> for VOID return type
if (ReactiveUtils.isReactiveReturnTypeOfVoid(this.toolMethod)) {
return monoFromPublisher
.then(Mono.just(CallToolResult.builder().addTextContent(jsonHelper.toJson("Done")).build()))
.onErrorResume(this::toErrorResultOrPropagate);
}
// Handle other Publisher types by mapping the emitted value
return monoFromPublisher.map(this::mapValueToCallToolResult).onErrorResume(this::toErrorResultOrPropagate);
}
// This should not happen in async context, but handle as fallback
throw new IllegalStateException(
"Expected reactive return type but got: " + (result != null ? result.getClass().getName() : "null"));
}
/**
* Map individual values to CallToolResult This method delegates to the parent class's
* convertValueToCallToolResult method to avoid code duplication.
* @param value The value to map
* @return A CallToolResult representing the mapped value
*/
protected CallToolResult mapValueToCallToolResult(Object value) {
return convertValueToCallToolResult(value);
}
/**
* Resolves a reactive error either into an error {@link CallToolResult} conveyed to
* the model, or into a propagated error that fails the model interaction. Mirrors the
* {@code @Tool} contract: ordinary {@link RuntimeException}s are conveyed to the
* model, while declared checked exceptions (carried asView on GitHub (pinned to 98a7beda4f)
Solutions
- Ensure the method registered on the async callback actually returns a reactive type (Mono/Flux)
- Remove proxies or wrappers that convert the return to a plain object before conversion
- Wrap the plain result: return Mono.just(value) in the tool method
- If the method is inherently synchronous, register it with the sync tool callback instead
Example fix
// before
String myTool(String arg) { return doWork(arg); } // registered async
// after
Mono<String> myTool(String arg) { return Mono.just(doWork(arg)); } Defensive patterns
Strategy: type-guard
Type guard
boolean isReactiveResult(Object r) {
return r instanceof Mono<?> || r instanceof Flux<?> || r instanceof org.reactivestreams.Publisher<?>;
} Try / catch
try { result = asyncCallback.call(args); } catch (IllegalStateException e) { log.error("Non-reactive tool result: {}", e.getMessage()); } Prevention
- Ensure async tool methods always return Publisher types end to end
- Beware AOP proxies that wrap and unwrap reactive return values
- Keep sync methods registered with sync callbacks
When it happens
Trigger: An async tool method declared to return a reactive type but whose invocation produced a plain object (e.g. wrapped/decorated method), or a custom subclass feeding a non-Publisher result into the async conversion path.
Common situations: Proxying/AOP around tool methods that changes the effective return type; custom async callback subclasses overriding conversion; framework version upgrades where return-type validation changed.
Understand the failure class
Background: Type mismatch errors: IllegalArgumentException, TypeError and type guards across 150 open-source libraries — this error's family across 150 libraries.
Related errors
- Mono return type must be Mono<Void>: {method.getName()} in {
- Method must return Mono<CreateMessageResult> or CreateMessag
- ASYNC Providers don't support imperative (non-reactive) retu
- SYNC Providers don't support reactive return types. Skipping
- Expected Mono<Void> but got Mono<
AI-assisted analysis of spring-projects/spring-ai@98a7beda4f (2026-09-11).
Data as JSON: /api/errors/7349970929125d5a.
Report an issue: GitHub.