Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 34 additions & 2 deletions sentry_sdk/integrations/mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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, ...]",
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
74 changes: 73 additions & 1 deletion tests/integrations/mcp/test_mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down