From 24d8bf672675227e47fa691354300a3ca24d9307 Mon Sep 17 00:00:00 2001 From: Zelys Date: Mon, 13 Apr 2026 16:19:56 -0500 Subject: [PATCH 1/2] feat(mcp): preserve CallToolResult.isError flag in MCPToolResult Adds an optional isError field to MCPToolResult, set to True only when the underlying MCP tool returned CallToolResult.isError=True. This lets callers tell apart application-level errors from protocol/transport errors, which currently both surface as status='error'. Closes #1670 --- src/strands/tools/mcp/mcp_client.py | 2 ++ src/strands/tools/mcp/mcp_types.py | 6 ++++++ tests/strands/tools/mcp/test_mcp_client.py | 13 +++++++++++++ 3 files changed, 21 insertions(+) diff --git a/src/strands/tools/mcp/mcp_client.py b/src/strands/tools/mcp/mcp_client.py index e81dc7130..a1cc74634 100644 --- a/src/strands/tools/mcp/mcp_client.py +++ b/src/strands/tools/mcp/mcp_client.py @@ -744,6 +744,8 @@ def _handle_tool_result(self, tool_use_id: str, call_tool_result: MCPCallToolRes result["structuredContent"] = call_tool_result.structuredContent if call_tool_result.meta: result["metadata"] = call_tool_result.meta + if call_tool_result.isError: + result["isError"] = True return result diff --git a/src/strands/tools/mcp/mcp_types.py b/src/strands/tools/mcp/mcp_types.py index 8fbf573be..09feb624f 100644 --- a/src/strands/tools/mcp/mcp_types.py +++ b/src/strands/tools/mcp/mcp_types.py @@ -61,7 +61,13 @@ class MCPToolResult(ToolResult): metadata: Optional arbitrary metadata returned by the MCP tool. This field allows MCP servers to attach custom metadata to tool results (e.g., token usage, performance metrics, or business-specific tracking information). + isError: Whether the MCP tool reported an application-level error via + ``CallToolResult.isError``. ``True`` means the tool executed but its logic + returned a failure. Absent when the tool succeeded or when the error was a + protocol/client exception rather than a tool-reported failure, letting + callers distinguish application errors from transport/protocol errors. """ structuredContent: NotRequired[dict[str, Any]] metadata: NotRequired[dict[str, Any]] + isError: NotRequired[bool] diff --git a/tests/strands/tools/mcp/test_mcp_client.py b/tests/strands/tools/mcp/test_mcp_client.py index bf0e7ce8e..47052c634 100644 --- a/tests/strands/tools/mcp/test_mcp_client.py +++ b/tests/strands/tools/mcp/test_mcp_client.py @@ -132,6 +132,8 @@ def test_call_tool_sync_status(mock_transport, mock_session, is_error, expected_ assert result["content"][0]["text"] == "Test message" # No structured content should be present when not provided by MCP assert result.get("structuredContent") is None + # isError flag should be True only when the tool reported an application error + assert result.get("isError") == (True if is_error else None) def test_call_tool_sync_session_not_active(): @@ -261,6 +263,8 @@ async def mock_awaitable(): assert result["toolUseId"] == "test-123" assert len(result["content"]) == 1 assert result["content"][0]["text"] == "Test message" + # isError flag should be True only when the tool reported an application error + assert result.get("isError") == (True if is_error else None) @pytest.mark.asyncio @@ -408,6 +412,15 @@ def test_mcp_tool_result_type(): assert result_with_structured["structuredContent"] == {"key": "value"} + # isError is optional — absent by default + assert "isError" not in result or result.get("isError") is None + + # isError can be set to flag tool-reported application errors + result_with_is_error = MCPToolResult( + status="error", toolUseId="test-789", content=[{"text": "Tool failed"}], isError=True + ) + assert result_with_is_error["isError"] is True + def test_call_tool_sync_without_structured_content(mock_transport, mock_session): """Test that call_tool_sync works correctly when no structured content is provided.""" From 59150c872bb0808b6a7147bac5b9a0c9df630ac9 Mon Sep 17 00:00:00 2001 From: Zelys Date: Wed, 15 Apr 2026 17:20:43 -0500 Subject: [PATCH 2/2] refactor(mcp): pass through isError value instead of hardcoding True Per maintainer feedback: use 'if call_tool_result.isError is not None' so both True and False are preserved in the result. The field is absent only when a protocol/client exception bypasses _handle_tool_result. Updated test assertions to match. --- src/strands/tools/mcp/mcp_client.py | 4 ++-- tests/strands/tools/mcp/test_mcp_client.py | 10 +++++----- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/strands/tools/mcp/mcp_client.py b/src/strands/tools/mcp/mcp_client.py index a1cc74634..aaa6d4615 100644 --- a/src/strands/tools/mcp/mcp_client.py +++ b/src/strands/tools/mcp/mcp_client.py @@ -744,8 +744,8 @@ def _handle_tool_result(self, tool_use_id: str, call_tool_result: MCPCallToolRes result["structuredContent"] = call_tool_result.structuredContent if call_tool_result.meta: result["metadata"] = call_tool_result.meta - if call_tool_result.isError: - result["isError"] = True + if call_tool_result.isError is not None: + result["isError"] = call_tool_result.isError return result diff --git a/tests/strands/tools/mcp/test_mcp_client.py b/tests/strands/tools/mcp/test_mcp_client.py index 47052c634..d40972219 100644 --- a/tests/strands/tools/mcp/test_mcp_client.py +++ b/tests/strands/tools/mcp/test_mcp_client.py @@ -132,8 +132,8 @@ def test_call_tool_sync_status(mock_transport, mock_session, is_error, expected_ assert result["content"][0]["text"] == "Test message" # No structured content should be present when not provided by MCP assert result.get("structuredContent") is None - # isError flag should be True only when the tool reported an application error - assert result.get("isError") == (True if is_error else None) + # isError mirrors the MCP server's explicit value; absent only for protocol/client exceptions + assert result.get("isError") is is_error def test_call_tool_sync_session_not_active(): @@ -263,8 +263,8 @@ async def mock_awaitable(): assert result["toolUseId"] == "test-123" assert len(result["content"]) == 1 assert result["content"][0]["text"] == "Test message" - # isError flag should be True only when the tool reported an application error - assert result.get("isError") == (True if is_error else None) + # isError mirrors the MCP server's explicit value; absent only for protocol/client exceptions + assert result.get("isError") is is_error @pytest.mark.asyncio @@ -413,7 +413,7 @@ def test_mcp_tool_result_type(): assert result_with_structured["structuredContent"] == {"key": "value"} # isError is optional — absent by default - assert "isError" not in result or result.get("isError") is None + assert "isError" not in result # isError can be set to flag tool-reported application errors result_with_is_error = MCPToolResult(