Skip to content

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

Description

@ReuDa

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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions