Skip to content
Draft
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
21 changes: 21 additions & 0 deletions sentry_sdk/integrations/mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,21 @@ def _set_span_input_data(
_set_span_data_attribute(span, f"mcp.request.argument.{k}", safe_serialize(v))


def _is_error_tool_result(result: "Any") -> bool:
"""
Whether a tool call result reports a tool-level error (`isError` is true).

The result is a CallToolResult object (`isError` in mcp v1, `is_error` in
mcp v2) or its serialized dict form (`isError`).
"""
if isinstance(result, dict):
return result.get("isError") is True
return (
getattr(result, "isError", None) is True
or getattr(result, "is_error", None) is True
)


def _extract_tool_result_content(result: "Any") -> "Any":
"""
Extract meaningful content from MCP tool result.
Expand Down Expand Up @@ -367,6 +382,9 @@ async def _tool_handler_wrapper(
if result is None:
return result

if _is_error_tool_result(result):
_set_span_data_attribute(span, SPANDATA.ERROR_TYPE, "tool_error")

# Get integration to check PII settings
integration = client.get_integration(MCPIntegration)
if integration is None:
Expand Down Expand Up @@ -461,6 +479,9 @@ async def _instrument_v2_tool_call(
_capture_exception(e)
raise

if _is_error_tool_result(result):
_set_span_data_attribute(span, SPANDATA.ERROR_TYPE, "tool_error")

if not isinstance(result, dict):
return result

Expand Down
147 changes: 146 additions & 1 deletion tests/integrations/mcp/test_mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,12 @@ 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 @@ -749,6 +754,7 @@ def failing_tool(tool_name, arguments):
assert not error_payload["exception"]["values"][0]["mechanism"]["handled"]

assert span["status"] == "error"
assert SPANDATA.ERROR_TYPE not in span["attributes"]
else:
events = capture_events()
with start_transaction(name="mcp tx"):
Expand Down Expand Up @@ -786,6 +792,145 @@ def failing_tool(tool_name, arguments):

assert span["status"] == "internal_error"
assert span["tags"]["status"] == "internal_error"
assert SPANDATA.ERROR_TYPE not in span["data"]


@pytest.mark.asyncio
@pytest.mark.skipif(
MCP_PACKAGE_VERSION < (1, 19, 0),
reason="Handlers can only return a CallToolResult from mcp 1.19",
)
@pytest.mark.parametrize("span_streaming", [True, False])
@pytest.mark.parametrize("is_error", [True, False])
async def test_tool_result_is_error(
sentry_init, capture_events, capture_items, span_streaming, is_error, stdio
):
"""A tool result with isError=true sets error.type, but not the span status"""
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 test_tool(ctx, params):
return CallToolResult(
content=[TextContent(type="text", text="warehouse DB timeout")],
is_error=is_error,
)

server.add_request_handler("tools/call", CallToolRequestParams, test_tool)
else:

@server.call_tool()
async def test_tool(tool_name, arguments):
return CallToolResult(
content=[TextContent(type="text", text="warehouse DB timeout")],
isError=is_error,
)

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": "lookup", "arguments": {}},
request_id="req-is-error",
)
sentry_sdk.flush()

assert not [item for item in items if item.type == "event"]
span = _find_mcp_span(items, method_name="tools/call")
assert span is not None
data = span["attributes"]
else:
events = capture_events()
with start_transaction(name="mcp tx"):
result = await stdio(
server,
method="tools/call",
params={"name": "lookup", "arguments": {}},
request_id="req-is-error",
)

(tx,) = events
assert len(tx["spans"]) == 1
span = tx["spans"][0]
data = span["data"]

assert _get_response(result).result["isError"] is is_error
# The span status is left as it is for a successful call
if span_streaming:
assert span["status"] == "ok"
else:
assert "status" not in span

if is_error:
assert data[SPANDATA.ERROR_TYPE] == "tool_error"
else:
assert SPANDATA.ERROR_TYPE not in data


@pytest.mark.asyncio
@pytest.mark.skipif(not IS_MCP_V2, reason="MCPServer is MCP v2 only")
@pytest.mark.parametrize("span_streaming", [True, False])
async def test_mcpserver_raising_tool_sets_error_type(
sentry_init, capture_events, capture_items, span_streaming, stdio
):
"""MCPServer turns an exception raised by a tool into an isError result
before the middleware sees it, so the span gets error.type=tool_error."""
from mcp.server import MCPServer

sentry_init(
integrations=[MCPIntegration()],
traces_sample_rate=1.0,
trace_lifecycle="stream" if span_streaming else "static",
)

mcp_server = MCPServer("test-server")

@mcp_server.tool()
def failing(x: int) -> int:
raise ValueError("Tool execution failed")

if span_streaming:
items = capture_items("span")
with sentry_sdk.traces.start_span(name="mcp tx"):
result = await stdio(
mcp_server._lowlevel_server,
method="tools/call",
params={"name": "failing", "arguments": {"x": 1}},
request_id="req-mcpserver-error",
)
sentry_sdk.flush()
span = _find_mcp_span(items, method_name="tools/call")
assert span is not None
data = span["attributes"]
else:
events = capture_events()
with start_transaction(name="mcp tx"):
result = await stdio(
mcp_server._lowlevel_server,
method="tools/call",
params={"name": "failing", "arguments": {"x": 1}},
request_id="req-mcpserver-error",
)
# MCPServer also logs the unexpected exception, which is captured as an event
(tx,) = (event for event in events if event.get("type") == "transaction")
assert len(tx["spans"]) == 1
span = tx["spans"][0]
data = span["data"]

assert _get_response(result).result["isError"] is True
if span_streaming:
assert span["status"] == "ok"
else:
assert "status" not in span
assert data[SPANDATA.ERROR_TYPE] == "tool_error"


@pytest.mark.asyncio
Expand Down