Skip to content

Propagate tool failures as ToolExecutionException so MCP clients see isError - #1781

Merged
alexheifetz merged 1 commit into
mainfrom
bugfix/1780-mcp-tool-iserror
Jul 13, 2026
Merged

Propagate tool failures as ToolExecutionException so MCP clients see isError#1781
alexheifetz merged 1 commit into
mainfrom
bugfix/1780-mcp-tool-iserror

Conversation

@jimador

@jimador jimador commented Jul 13, 2026

Copy link
Copy Markdown
Collaborator

Fixes #1780.

SpringToolCallbackAdapter.call swallowed both Tool.Result.Error and thrown exceptions into "ERROR: ..." string results, so MCP clients received successful tools/call responses with CallToolResult.isError = false for every tool failure — including access-control denials.

What changed

  • The adapter now throws ToolExecutionException for both failure kinds, always with a RuntimeException cause carrying the intended message:
    • Tool.Result.Error(message, cause)ToolExecutionException(definition, RuntimeException(message, cause)) — the error message survives verbatim on the wire.
    • Thrown RuntimeException → passed as the cause directly, preserving the concrete class (e.g. AccessDeniedException) for exception processors configured to rethrow specific types.
    • Checked exceptions → wrapped in RuntimeException first. This matters: DefaultToolExecutionExceptionProcessor rethrows when the cause is not a RuntimeException, which would break the chat tool loop.
  • Success paths (Text, WithArtifact) unchanged; SpringToolCallbackWrapper (the reverse adapter) unchanged.

Behavior after the change

  • MCP: Spring AI's McpToolUtils handler catches the exception → isError = true with the message as content. McpAwareGoalTool already maps AccessDeniedException to Tool.Result.Error, so security denials become protocol-level errors with no further change.
  • LLM chat loop: DefaultToolCallingManager catches exactly ToolExecutionException and its processor returns the message to the model — same visible behavior as before, minus the "ERROR: " prefix.

On the issue's open question (soft vs hard errors): both Result.Error and exceptions map to protocol errors — a tool that returns Result.Error failed, and clients branching on isError should see it. No config surface added; easy to revisit if a use case needs split behavior.

Testing

  • SpringToolCallbackAdapterTest updated from string-prefix assertions to assertThrows with message assertions.
  • New McpToolErrorMappingTest drives the real McpToolUtils.toSyncToolSpecification handler end to end: isError = true for runtime, Result.Error, and checked-exception cases (asserting the RuntimeException-cause invariant); happy path stays non-error.
  • Failure-path tests confirmed failing against the pre-fix code; full embabel-agent-api suite green (3907 tests, 0 failures).
…isError

SpringToolCallbackAdapter.call used to encode every tool failure (Result.Error
and thrown exceptions alike) as a plain "ERROR: ..." string. From the MCP
client's point of view that looked like a successful call, so isError stayed
false even when the tool genuinely failed (#1780).

Both failure paths now throw ToolExecutionException instead, always wrapped
around a RuntimeException so Spring AI's chat tool loop still converts it into
a message for the model rather than rethrowing it. An already-thrown
ToolExecutionException passes through unchanged.

Signed-off-by: James Dunnam <7660553+jimador@users.noreply.github.com>

@igordayen igordayen left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jimador - thanks for yet another discovery and solution. left few comments. would be good to test with streaming as well. thanks again

@alexheifetz
alexheifetz self-requested a review July 13, 2026 21:15

@alexheifetz alexheifetz left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Merging. Streaming Test will be added later.

@alexheifetz
alexheifetz merged commit a003485 into main Jul 13, 2026
17 checks passed
@alexheifetz
alexheifetz deleted the bugfix/1780-mcp-tool-iserror branch July 13, 2026 21:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

3 participants