spring-projects / spring-projects/spring-ai

[Bug - MCP server] @McpTool error messages are emitted twice when the thrown exception has no cause

Open Beginner friendly
#6,948 1 comment 1 reaction 0 assignees View on GitHub

Nobody has claimed this yet.

status: waiting-for-triage
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 / SyncStatelessMcpToolMethodCallbackAbstractSyncMcpToolMethodCallback#createSyncErrorResult
  • AsyncMcpToolMethodCallback / AsyncStatelessMcpToolMethodCallbackAbstractAsyncMcpToolMethodCallback#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

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.