spring-projects / spring-projects/spring-ai
[Bug - MCP server] @McpTool error messages are emitted twice when the thrown exception has no cause
Nobody has claimed this yet.
- Dominant language
- Java
- Stars
- 9.5k
- Forks
- 2.9k
- Avg merge
- 1d 7h
- Merged PRs (30d)
- 6
Description
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#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.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.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start with createSyncErrorResult in AbstractSyncMcpToolMethodCallback and createAsyncErrorResult in AbstractAsyncMcpToolMethodCallback, then trace the affected SyncMcpToolMethodCallback, SyncStatelessMcpToolMethodCallback, AsyncMcpToolMethodCallback, and AsyncStatelessMcpToolMethodCallback paths. Verify the reproduced bare IllegalArgumentException case and the wrapped-exception case; done means the bare message is emitted once while an informative root cause remains included.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- java
- Domain
- api, backend
- Issue type
- Bug
- Difficulty
- 2/5
- Estimated time
- 1-3 hours
- Activity status
- Active
- Clarity
- Clearly specified
- Newbie friendliness
- 78/100