Skip to content

BAI-344 propagate MCP tool isError as ToolException in call_tool - #63

Merged
imranq2 merged 1 commit into
mainfrom
IQ-BAI-344
Jul 26, 2026
Merged

BAI-344 propagate MCP tool isError as ToolException in call_tool#63
imranq2 merged 1 commit into
mainfrom
IQ-BAI-344

Conversation

@imranq2

@imranq2 imranq2 commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

Summary

  • CallToolTool._arun converted a failed inner MCP tool call (result.isError=True, missing/invalid arguments, tool-not-found, or a transport exception) into ordinary text content instead of an error-flagged ToolMessage. FastMCP already reports validation failures as isError=True (confirmed by tracing mcp/server/lowlevel/server.py's _make_error_result), consistent with SEP-1303 ("Input Validation Errors as Tool Execution Errors") — but this wrapper discarded that signal before it reached the calling model.
  • Observed impact: a live chat trace showed the model calling propose_skill (baileyai-skills-service) 36 times in a row via call_tool with the arguments payload omitted entirely. The model never received a real failure signal, so it kept retrying the same malformed call instead of adjusting its arguments.
  • Fix: raise ToolException on all three failure paths in _arun (tool not found, inner result.isError, transport exception) and set handle_tool_error: bool = True on CallToolTool. LangChain's BaseTool.handle_tool_error defaults to False (a raised ToolException just re-raises); setting it True is what makes the caught exception become a ToolMessage with status="error" instead of looking like an ordinary successful call.
  • This fixes error visibility for every MCP tool routed through call_tool, not just one server's tools.

Related

  • baileyai-skills-service PR #66 worked around this for three specific tools (propose_skill/propose_resource/propose_script) by giving required fields empty-string defaults and validating manually, so FastMCP's own schema-validation error is bypassed in favor of the tool's own actionable rejection message. That fix stays — it improves message wording — but this PR is the root-cause fix for the actual retry-loop incident, since it fixes error visibility rather than error wording.

Test plan

  • Updated tests/mcp/test_call_tool_tool.py: existing tests now assert ToolException is raised (not a (text, artifact) tuple) for the not-found/transport-error/inner-error-result paths. Added test_inner_tool_error_result_sets_tool_message_status_error, an end-to-end test going through the real BaseTool.arun to confirm the resulting ToolMessage.status == "error".
  • uv run pytest tests/mcp tests/converters — 305 passed
  • uv run ruff check / ruff format --check / mypy — clean
  • Full Dockerized pre-commit suite (ruff, ruff-format, mypy, bandit, detect-secrets, standard hooks) — all passed

🤖 Generated with Claude Code

CallToolTool converted a failed inner tool call (result.isError=True,
missing/invalid arguments, tool-not-found, transport errors) into
ordinary text content instead of an error-flagged ToolMessage. FastMCP
already reports these as isError=True per SEP-1303, but this wrapper
discarded that signal, so a model reading the response saw what looked
like a normal success and kept retrying blindly instead of adjusting
its arguments (observed: propose_skill called 36 times in a row with
no arguments).

Raise ToolException on all three failure paths and set
handle_tool_error=True so LangChain marks the resulting ToolMessage
status="error", which is the signal calling models/harnesses actually
react to.

@gecko-security gecko-security Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Gecko PR Review

No vulnerabilities found, LGTM.

Summary

This PR refactors languagemodelcommon/tools/mcp/call_tool_tool.py to use ToolException for error signaling instead of returning error tuples, enabling LangChain's BaseTool to properly mark failed tool calls with status="error" in resulting ToolMessage objects. The change sets handle_tool_error: bool = True and raises ToolException on three failure paths: missing tools, MCP server errors (result.isError), and unexpected exceptions. This ensures calling models can distinguish between successful and failed MCP tool invocations.

Important Files Changed

2 important files

File Path Why it matters
languagemodelcommon/tools/mcp/call_tool_tool.py Core change: replaces error-tuple return pattern with ToolException raises on missing tools, MCP errors, and unexpected exceptions, coupled with handle_tool_error: bool = True to enable proper error status in LangChain message contracts.
tests/mcp/test_call_tool_tool.py Test file must be updated to verify that ToolException is now raised instead of tuples returned, and that error paths propagate correctly to callers.

Sequence Diagram

sequenceDiagram
    participant Model
    participant CallToolTool
    participant ToolCatalog
    participant MCPServer
    Model->>CallToolTool: invoke(name, args)
    CallToolTool->>ToolCatalog: get_tool(name)
    alt Tool not found
        ToolCatalog-->>CallToolTool: None
        CallToolTool->>CallToolTool: raise ToolException
        CallToolTool-->>Model: ToolMessage(status="error")
    else Tool found
        ToolCatalog-->>CallToolTool: entry
        CallToolTool->>MCPServer: call_tool()
        alt MCP call succeeds but result.isError=true
            MCPServer-->>CallToolTool: CallToolResult(isError=true)
            CallToolTool->>CallToolTool: raise ToolException
            CallToolTool-->>Model: ToolMessage(status="error")
        else MCP call succeeds normally
            MCPServer-->>CallToolTool: CallToolResult(isError=false)
            CallToolTool-->>Model: ToolMessage(status="success")
        end
    end
Loading

Edit Gecko PR Settings | Reviewed commit: 8c2c15b

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

LGTM — the ToolException + handle_tool_error mechanism is correct and verified against the LangChain source (BaseTool.arun catches ToolException, sets status="error" via _handle_tool_error when handle_tool_error=True), and the AuthorizationNeededException re-raise path is preserved. Only issue found is a stale comment (flagged inline), which doesn't affect behavior.

Extended reasoning...

Overview

This PR changes error signaling in CallToolTool._arun (languagemodelcommon/tools/mcp/call_tool_tool.py) from returning (text, {"is_error": True}) tuples to raising ToolException on all three failure paths (tool not found, inner result.isError, transport exception), paired with handle_tool_error: bool = True. Tests in tests/mcp/test_call_tool_tool.py were updated accordingly, including a new end-to-end test that exercises the real BaseTool.arun to confirm ToolMessage.status == "error".

Security risks

None. This is purely an internal error-signaling mechanism between a wrapper tool and the calling LLM/agent harness — no new external inputs, no auth/crypto/tenant-boundary code touched. The existing AuthorizationNeededException re-raise (used for login-link flows) is preserved unchanged and still takes precedence over the new ToolException handling.

Level of scrutiny

I verified the core claim against the actual langchain_core source (found a local extracted copy): BaseTool.arun catches ToolException, and when handle_tool_error=True it calls _handle_tool_error and sets status = "error" on the resulting ToolMessage — exactly as the PR description claims. This is a small, well-scoped bug fix (root-causing a real production retry-loop incident per the PR description) with direct test coverage of both the unit-level (_arun raises) and integration-level (arun produces the right ToolMessage status) behavior. Low risk given the scope and quality of tests.

Other factors

The one substantive issue found — a stale comment in tool_event_handlers.py (and a matching test docstring) that still describes the old artifact-based signaling this PR removes — is a documentation-only nit with no runtime impact (the consuming is_error check is an OR of both signals, so it still evaluates correctly either way). It's already captured as an inline comment. No outstanding unaddressed reviewer comments were found in the timeline.

Comment on lines 114 to 128
)
text = _call_tool_result_to_text(result)

# Surface the *inner* tool's own error status (result.isError), not just
# transport-level failures — MCP servers report rejected/invalid calls
# this way rather than raising, so this is the only place that signal
# exists. Raising here (with handle_tool_error=True) is what makes the
# resulting ToolMessage carry status="error" instead of looking like an
# ordinary successful call to the model.
if result.isError:
raise ToolException(text)

app_embed = await self.mcp_tool_provider.fetch_mcp_app_embed(
tool=entry.tool,
tool_name=name,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 This PR changes CallToolTool's error signaling from artifact={"is_error": True} to raising ToolException, but languagemodelcommon/converters/tool_event_handlers.py:184-189 still has a comment (and tests/converters/test_tool_event_handlers.py:161-163 a matching docstring) claiming CallToolTool reports failures via the artifact — both now describe a contract this PR removed. Runtime behavior is unaffected since the dual-signal check (status=='error' OR artifact.get('is_error')) still passes via the status branch, but the comment/docstring should be updated since it explicitly cites CallToolTool as the rationale.

Extended reasoning...

What's stale

languagemodelcommon/converters/tool_event_handlers.py:184-186 contains a comment explaining the dual-signal is_error check:

tool_message.status only flips to "error" for an uncaught exception; meta-tools like call_tool catch their own failures and report them via the artifact instead (see CallToolTool).

tests/converters/test_tool_event_handlers.py:161-163 has a matching docstring making the same claim, and constructs a ToolMessage with artifact={"is_error": True} to simulate what it says is CallToolTool's output.

Why it's now wrong

This PR (languagemodelcommon/tools/mcp/call_tool_tool.py:114-128) inverts exactly the contract these two comments describe. CallToolTool._arun no longer returns artifact={"is_error": True} on failure — that code path was deleted. Instead, on result.isError, tool-not-found, or a transport exception, it now raises ToolException, and handle_tool_error: bool = True makes BaseTool.arun catch that exception and set ToolMessage.status = "error". So both clauses of the comment are now false: call_tool's status does flip to "error" (via a handled, not uncaught, exception), and it no longer reports through the artifact at all.

Why nothing breaks today

tool_event_handlers.py implements the is_error check as status == "error" OR artifact.get("is_error") — an OR of two signals. Before this PR, call_tool failures satisfied it via the artifact branch; after this PR, they satisfy it via the status branch instead. The check still evaluates to True for a failed call_tool invocation either way, so the runtime logic that consumes this signal is unaffected. Neither tool_event_handlers.py nor its test file is touched by this PR — it only modified call_tool_tool.py and tests/mcp/test_call_tool_tool.py.

Concrete walkthrough

  1. Before this PR: call_tool calls a rejecting MCP tool → _arun returns ("Tool call failed: ...", {"is_error": True})BaseTool builds a ToolMessage with status="success" (no exception raised) and artifact={"is_error": True}tool_event_handlers.py's check status=="error" or artifact.get("is_error") evaluates False or True = True. Matches the comment's description exactly.
  2. After this PR: same call → _arun raises ToolException("Tool call failed: ...") → because handle_tool_error=True, BaseTool.arun catches it and builds a ToolMessage with status="error" and no is_error artifact key → the same check evaluates True or False = True. Same net result, but now reached via the other branch — the one the comment says never happens for call_tool.

Fix

Update the comment in tool_event_handlers.py:184-186 to drop the now-incorrect claim that call_tool reports via the artifact (or simply state that both signals are checked because different tools/versions may use either), and update the docstring/fixture in test_tool_event_handlers.py:161-163 to stop citing CallToolTool as the source of the artifact={"is_error": True} pattern — the artifact branch may still be exercised by other meta-tools, but not by CallToolTool anymore.

This is a documentation-only staleness issue with no behavioral impact, so it should not block merge, but it's worth a quick follow-up since this very PR is what changed the contract being described.

@imranq2
imranq2 merged commit 1f120df into main Jul 26, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants