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
19 changes: 19 additions & 0 deletions sentry_sdk/integrations/mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -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, ...]",
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down
47 changes: 46 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,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(
Expand Down