From cab0286f3119b35ca6b3973645c8bf1d609dce55 Mon Sep 17 00:00:00 2001 From: AP Date: Mon, 12 Oct 2026 09:56:03 +0530 Subject: [PATCH] fix(mcp): mark isError:true tool results as span errors When an MCP tool fails without raising (returns CallToolResult(isError=True)), the integration never inspected the isError flag, so error rates read 0%%. Check the flag on the result in both the v1 wrapper and v2 middleware and mark the span error, matching the mcp OTel middleware behavior. Fixes getsentry/sentry-python#7890. --- sentry_sdk/integrations/mcp.py | 36 ++++++++++++++- tests/integrations/mcp/test_mcp.py | 74 +++++++++++++++++++++++++++++- 2 files changed, 107 insertions(+), 3 deletions(-) diff --git a/sentry_sdk/integrations/mcp.py b/sentry_sdk/integrations/mcp.py index 5fc186f505..d76c47341f 100644 --- a/sentry_sdk/integrations/mcp.py +++ b/sentry_sdk/integrations/mcp.py @@ -15,10 +15,10 @@ import sentry_sdk from sentry_sdk.ai.utils import _set_span_data_attribute, get_start_span_function -from sentry_sdk.consts import OP, SPANDATA +from sentry_sdk.consts import OP, SPANDATA, SPANSTATUS from sentry_sdk.integrations import DidNotEnable, Integration from sentry_sdk.scope import should_send_default_pii -from sentry_sdk.traces import StreamedSpan +from sentry_sdk.traces import SpanStatus, StreamedSpan from sentry_sdk.tracing_utils import has_span_streaming_enabled from sentry_sdk.utils import ( capture_internal_exceptions, @@ -270,6 +270,32 @@ def _extract_text_from_content_blocks(content_blocks: "Any") -> "Any": return " ".join(texts) if texts else content_blocks +def _is_tool_error_result(result: "Any") -> bool: + """ + Check whether a tool call result represents a tool error. + + Tool errors surface as an `isError` flag on the result instead of raising: + - mcp v2+: the middleware observes the serialized wire dict (`{"isError": True}`) + - mcp v1: the handler returns a `CallToolResult` with `isError` set + """ + if isinstance(result, dict): + return result.get("isError") is True + is_error = getattr(result, "isError", None) + if is_error is None: + is_error = getattr(result, "is_error", False) + return bool(is_error) + + +def _mark_tool_error_span(span: "Union[StreamedSpan, Span]") -> None: + """Mark a tools/call span as failed because the tool reported isError.""" + if isinstance(span, StreamedSpan): + # StreamedSpan only supports "ok"/"error" statuses + span.status = SpanStatus.ERROR + else: + span.set_status(SPANSTATUS.INTERNAL_ERROR) + _set_span_data_attribute(span, SPANDATA.ERROR_TYPE, "tool_error") + + async def _tool_handler_wrapper( func: "Callable[..., Awaitable[Union[CallToolResult, InputRequiredResult]]]", original_args: "tuple[Any, ...]", @@ -367,6 +393,9 @@ async def _tool_handler_wrapper( if result is None: return result + if _is_tool_error_result(result): + _mark_tool_error_span(span) + # Get integration to check PII settings integration = client.get_integration(MCPIntegration) if integration is None: @@ -464,6 +493,9 @@ async def _instrument_v2_tool_call( if not isinstance(result, dict): return result + if _is_tool_error_result(result): + _mark_tool_error_span(span) + # Get integration to check PII settings integration = client.get_integration(MCPIntegration) if integration is None: diff --git a/tests/integrations/mcp/test_mcp.py b/tests/integrations/mcp/test_mcp.py index 15432d8d84..06c7e76c63 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,78 @@ def failing_tool(tool_name, arguments): assert span["tags"]["status"] == "internal_error" +@pytest.mark.asyncio +@pytest.mark.parametrize("span_streaming", [True, False]) +async def test_tool_handler_is_error_result( + sentry_init, capture_events, capture_items, span_streaming, stdio +): + """A tool returning isError=True must mark the tools/call span as failed.""" + sentry_init( + integrations=[MCPIntegration()], + traces_sample_rate=1.0, + trace_lifecycle="stream" if span_streaming else "static", + ) + + server = Server("test-server") + + if IS_MCP_V2: + + async def error_tool(ctx, params): + return CallToolResult( + content=[TextContent(type="text", text="Tool reported an error")], + is_error=True, + ) + + server.add_request_handler("tools/call", CallToolRequestParams, error_tool) + else: + + @server.call_tool() + def error_tool(tool_name, arguments): + return CallToolResult( + content=[TextContent(type="text", text="Tool reported an error")], + isError=True, + ) + + if span_streaming: + items = capture_items("event", "span") + with sentry_sdk.traces.start_span(name="mcp tx"): + result = await stdio( + server, + method="tools/call", + params={"name": "error_tool", "arguments": {}}, + request_id="req-is-error", + ) + sentry_sdk.flush() + + resp = _get_response(result) + assert resp.result["isError"] is True + + span = _find_mcp_span(items, method_name="tools/call") + assert span is not None + assert span["status"] == "error" + assert span["attributes"]["error.type"] == "tool_error" + else: + events = capture_events() + with start_transaction(name="mcp tx"): + result = await stdio( + server, + method="tools/call", + params={"name": "error_tool", "arguments": {}}, + request_id="req-is-error", + ) + + resp = _get_response(result) + assert resp.result["isError"] is True + + # Nothing raised, so only the transaction is captured (no error event) + (tx,) = events + assert tx["type"] == "transaction" + assert len(tx["spans"]) == 1 + span = tx["spans"][0] + assert span["status"] == "internal_error" + assert span["data"]["error.type"] == "tool_error" + + @pytest.mark.asyncio @pytest.mark.parametrize("span_streaming", [True, False]) @pytest.mark.parametrize(