Bug description
The @McpTool method callbacks build their error text by concatenating the exception's message with its root cause's message:
// AbstractSyncMcpToolMethodCallback#createSyncErrorResult
protected CallToolResult createSyncErrorResult(Exception e) {
Throwable rootCause = findCauseUsingPlainJava(e);
return CallToolResult.builder()
.isError(true)
.addTextContent(e.getMessage() + System.lineSeparator() + rootCause.getMessage())
.build();
}
findCauseUsingPlainJava starts from the throwable itself and only walks getCause():
Throwable rootCause = throwable;
while (rootCause.getCause() != null && rootCause.getCause() != rootCause) {
rootCause = rootCause.getCause();
}
return rootCause;
So for an exception with no cause it returns the exception itself, rootCause.getMessage() is the same string as e.getMessage(), and the client receives every such error twice, separated by a line separator.
This affects the most ordinary case there is: a tool that validates its own input and throws a bare IllegalArgumentException carrying an actionable message.
Affected paths:
SyncMcpToolMethodCallback / SyncStatelessMcpToolMethodCallback → AbstractSyncMcpToolMethodCallback#createSyncErrorResult
AsyncMcpToolMethodCallback / AsyncStatelessMcpToolMethodCallback → AbstractAsyncMcpToolMethodCallback#createAsyncErrorResult (identical concatenation)
Tool methods that return a reactive type take a different path — AbstractAsyncMcpToolMethodCallback#toErrorResultOrPropagate — which emits the message once, as "Error invoking method: %s". So the same server currently formats tool errors two different ways depending on a tool's return type, and only one of them duplicates.
Environment
Spring AI 2.0.1. Also verified still present on main (AbstractSyncMcpToolMethodCallback.java is unchanged), so this is not fixed by upgrading.
For context: #6456 / #6534 reworked which exceptions reach createSyncErrorResult (McpError and UndeclaredThrowableException now propagate instead of being converted), but left the message construction untouched.
Steps to reproduce
@Service
public class ToolService {
@McpTool(name = "find_thing", description = "Looks something up")
public String findThing(String name) {
throw new IllegalArgumentException("No thing named '" + name + "' found. Use list_things to see valid names.");
}
}
Call the tool. content[0].text is:
No thing named 'x' found. Use list_things to see valid names.
No thing named 'x' found. Use list_things to see valid names.
Expected behavior
The message once:
No thing named 'x' found. Use list_things to see valid names.
Appending the root cause is genuinely useful when the thrown exception wraps another, so the fix is to append it only when it adds information — e.g.:
protected CallToolResult createSyncErrorResult(Exception e) {
Throwable rootCause = findCauseUsingPlainJava(e);
String text = (rootCause == e || Objects.equals(rootCause.getMessage(), e.getMessage()))
? e.getMessage()
: e.getMessage() + System.lineSeparator() + rootCause.getMessage();
return CallToolResult.builder().isError(true).addTextContent(text).build();
}
and the same in createAsyncErrorResult.
Bug description
The
@McpToolmethod callbacks build their error text by concatenating the exception's message with its root cause's message:findCauseUsingPlainJavastarts from the throwable itself and only walksgetCause():So for an exception with no cause it returns the exception itself,
rootCause.getMessage()is the same string ase.getMessage(), and the client receives every such error twice, separated by a line separator.This affects the most ordinary case there is: a tool that validates its own input and throws a bare
IllegalArgumentExceptioncarrying an actionable message.Affected paths:
SyncMcpToolMethodCallback/SyncStatelessMcpToolMethodCallback→AbstractSyncMcpToolMethodCallback#createSyncErrorResultAsyncMcpToolMethodCallback/AsyncStatelessMcpToolMethodCallback→AbstractAsyncMcpToolMethodCallback#createAsyncErrorResult(identical concatenation)Tool methods that return a reactive type take a different path —
AbstractAsyncMcpToolMethodCallback#toErrorResultOrPropagate— which emits the message once, as"Error invoking method: %s". So the same server currently formats tool errors two different ways depending on a tool's return type, and only one of them duplicates.Environment
Spring AI 2.0.1. Also verified still present on
main(AbstractSyncMcpToolMethodCallback.javais unchanged), so this is not fixed by upgrading.For context: #6456 / #6534 reworked which exceptions reach
createSyncErrorResult(McpErrorandUndeclaredThrowableExceptionnow propagate instead of being converted), but left the message construction untouched.Steps to reproduce
Call the tool.
content[0].textis:Expected behavior
The message once:
Appending the root cause is genuinely useful when the thrown exception wraps another, so the fix is to append it only when it adds information — e.g.:
and the same in
createAsyncErrorResult.