diff --git a/sentry_sdk/integrations/mcp.py b/sentry_sdk/integrations/mcp.py index 5fc186f505..a3523266de 100644 --- a/sentry_sdk/integrations/mcp.py +++ b/sentry_sdk/integrations/mcp.py @@ -270,6 +270,19 @@ def _extract_text_from_content_blocks(content_blocks: "Any") -> "Any": return " ".join(texts) if texts else content_blocks +def _tool_result_is_error(result: "Any") -> bool: + """ + Return whether a tools/call result reports a tool error. + + A dict uses the wire key ``isError`` (what mcp 2.x middleware observes). + A result object uses the ``isError`` attribute (``CallToolResult`` on mcp + 1.x, and a non-dict result the v2 middleware can see). + """ + if isinstance(result, dict): + return result.get("isError") is True + return getattr(result, "isError", None) is True + + async def _tool_handler_wrapper( func: "Callable[..., Awaitable[Union[CallToolResult, InputRequiredResult]]]", original_args: "tuple[Any, ...]", @@ -364,6 +377,9 @@ async def _tool_handler_wrapper( _capture_exception(e) raise + if _tool_result_is_error(result): + _set_span_data_attribute(span, SPANDATA.ERROR_TYPE, "tool_error") + if result is None: return result @@ -461,6 +477,9 @@ async def _instrument_v2_tool_call( _capture_exception(e) raise + if _tool_result_is_error(result): + _set_span_data_attribute(span, SPANDATA.ERROR_TYPE, "tool_error") + if not isinstance(result, dict): return result diff --git a/tests/integrations/mcp/test_mcp.py b/tests/integrations/mcp/test_mcp.py index 15432d8d84..cee93751cc 100644 --- a/tests/integrations/mcp/test_mcp.py +++ b/tests/integrations/mcp/test_mcp.py @@ -63,7 +63,7 @@ async def __call__(self, *args, **kwargs): TextResourceContents, ) else: - from mcp.types import GetPromptResult, PromptMessage, TextContent + from mcp.types import CallToolResult, GetPromptResult, PromptMessage, TextContent from mcp.server.sse import SseServerTransport from mcp.server.streamable_http_manager import StreamableHTTPSessionManager @@ -788,6 +788,51 @@ def failing_tool(tool_name, arguments): assert span["tags"]["status"] == "internal_error" +@pytest.mark.asyncio +async def test_tool_is_error_result_sets_error_type(sentry_init, capture_events, stdio): + sentry_init( + integrations=[MCPIntegration()], + default_integrations=False, + traces_sample_rate=1.0, + trace_lifecycle="static", + ) + + if IS_MCP_V2: + from mcp.server import MCPServer + + mcp_server = MCPServer("test-server") + + @mcp_server.tool() + def bad_tool() -> str: + raise ValueError("bad input") + + server = mcp_server._lowlevel_server + else: + server = Server("test-server") + + @server.call_tool() + def bad_tool(tool_name, arguments): + return CallToolResult( + content=[TextContent(type="text", text="bad input")], + isError=True, + ) + + events = capture_events() + with start_transaction(name="mcp tx"): + result = await stdio( + server, + method="tools/call", + params={"name": "bad_tool", "arguments": {}}, + request_id="req-is-error", + ) + + assert _get_response(result).result["isError"] is True + (tx,) = events + (span,) = tx["spans"] + assert span["data"][SPANDATA.ERROR_TYPE] == "tool_error" + assert "status" not in span + + @pytest.mark.asyncio @pytest.mark.parametrize("span_streaming", [True, False]) @pytest.mark.parametrize(