Skip to content

Commit 3735763

Browse files
committed
fix(mcp): Set error.type for tool results with isError
1 parent ab069a5 commit 3735763

2 files changed

Lines changed: 167 additions & 1 deletion

File tree

‎sentry_sdk/integrations/mcp.py‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,21 @@ def _set_span_input_data(
218218
_set_span_data_attribute(span, f"mcp.request.argument.{k}", safe_serialize(v))
219219

220220

221+
def _is_error_tool_result(result: "Any") -> bool:
222+
"""
223+
Whether a tool call result reports a tool-level error (`isError` is true).
224+
225+
The result is a CallToolResult object (`isError` in mcp v1, `is_error` in
226+
mcp v2) or its serialized dict form (`isError`).
227+
"""
228+
if isinstance(result, dict):
229+
return result.get("isError") is True
230+
return (
231+
getattr(result, "isError", None) is True
232+
or getattr(result, "is_error", None) is True
233+
)
234+
235+
221236
def _extract_tool_result_content(result: "Any") -> "Any":
222237
"""
223238
Extract meaningful content from MCP tool result.
@@ -367,6 +382,9 @@ async def _tool_handler_wrapper(
367382
if result is None:
368383
return result
369384

385+
if _is_error_tool_result(result):
386+
_set_span_data_attribute(span, SPANDATA.ERROR_TYPE, "tool_error")
387+
370388
# Get integration to check PII settings
371389
integration = client.get_integration(MCPIntegration)
372390
if integration is None:
@@ -461,6 +479,9 @@ async def _instrument_v2_tool_call(
461479
_capture_exception(e)
462480
raise
463481

482+
if _is_error_tool_result(result):
483+
_set_span_data_attribute(span, SPANDATA.ERROR_TYPE, "tool_error")
484+
464485
if not isinstance(result, dict):
465486
return result
466487

‎tests/integrations/mcp/test_mcp.py‎

Lines changed: 146 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,12 @@ async def __call__(self, *args, **kwargs):
6363
TextResourceContents,
6464
)
6565
else:
66-
from mcp.types import GetPromptResult, PromptMessage, TextContent
66+
from mcp.types import (
67+
CallToolResult,
68+
GetPromptResult,
69+
PromptMessage,
70+
TextContent,
71+
)
6772

6873
from mcp.server.sse import SseServerTransport
6974
from mcp.server.streamable_http_manager import StreamableHTTPSessionManager
@@ -749,6 +754,7 @@ def failing_tool(tool_name, arguments):
749754
assert not error_payload["exception"]["values"][0]["mechanism"]["handled"]
750755

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

787793
assert span["status"] == "internal_error"
788794
assert span["tags"]["status"] == "internal_error"
795+
assert SPANDATA.ERROR_TYPE not in span["data"]
796+
797+
798+
@pytest.mark.asyncio
799+
@pytest.mark.skipif(
800+
MCP_PACKAGE_VERSION < (1, 19, 0),
801+
reason="Handlers can only return a CallToolResult from mcp 1.19",
802+
)
803+
@pytest.mark.parametrize("span_streaming", [True, False])
804+
@pytest.mark.parametrize("is_error", [True, False])
805+
async def test_tool_result_is_error(
806+
sentry_init, capture_events, capture_items, span_streaming, is_error, stdio
807+
):
808+
"""A tool result with isError=true sets error.type, but not the span status"""
809+
sentry_init(
810+
integrations=[MCPIntegration()],
811+
traces_sample_rate=1.0,
812+
trace_lifecycle="stream" if span_streaming else "static",
813+
)
814+
815+
server = Server("test-server")
816+
817+
if IS_MCP_V2:
818+
819+
async def test_tool(ctx, params):
820+
return CallToolResult(
821+
content=[TextContent(type="text", text="warehouse DB timeout")],
822+
is_error=is_error,
823+
)
824+
825+
server.add_request_handler("tools/call", CallToolRequestParams, test_tool)
826+
else:
827+
828+
@server.call_tool()
829+
async def test_tool(tool_name, arguments):
830+
return CallToolResult(
831+
content=[TextContent(type="text", text="warehouse DB timeout")],
832+
isError=is_error,
833+
)
834+
835+
if span_streaming:
836+
items = capture_items("event", "span")
837+
with sentry_sdk.traces.start_span(name="mcp tx"):
838+
result = await stdio(
839+
server,
840+
method="tools/call",
841+
params={"name": "lookup", "arguments": {}},
842+
request_id="req-is-error",
843+
)
844+
sentry_sdk.flush()
845+
846+
assert not [item for item in items if item.type == "event"]
847+
span = _find_mcp_span(items, method_name="tools/call")
848+
assert span is not None
849+
data = span["attributes"]
850+
else:
851+
events = capture_events()
852+
with start_transaction(name="mcp tx"):
853+
result = await stdio(
854+
server,
855+
method="tools/call",
856+
params={"name": "lookup", "arguments": {}},
857+
request_id="req-is-error",
858+
)
859+
860+
(tx,) = events
861+
assert len(tx["spans"]) == 1
862+
span = tx["spans"][0]
863+
data = span["data"]
864+
865+
assert _get_response(result).result["isError"] is is_error
866+
# The span status is left as it is for a successful call
867+
if span_streaming:
868+
assert span["status"] == "ok"
869+
else:
870+
assert "status" not in span
871+
872+
if is_error:
873+
assert data[SPANDATA.ERROR_TYPE] == "tool_error"
874+
else:
875+
assert SPANDATA.ERROR_TYPE not in data
876+
877+
878+
@pytest.mark.asyncio
879+
@pytest.mark.skipif(not IS_MCP_V2, reason="MCPServer is MCP v2 only")
880+
@pytest.mark.parametrize("span_streaming", [True, False])
881+
async def test_mcpserver_raising_tool_sets_error_type(
882+
sentry_init, capture_events, capture_items, span_streaming, stdio
883+
):
884+
"""MCPServer turns an exception raised by a tool into an isError result
885+
before the middleware sees it, so the span gets error.type=tool_error."""
886+
from mcp.server import MCPServer
887+
888+
sentry_init(
889+
integrations=[MCPIntegration()],
890+
traces_sample_rate=1.0,
891+
trace_lifecycle="stream" if span_streaming else "static",
892+
)
893+
894+
mcp_server = MCPServer("test-server")
895+
896+
@mcp_server.tool()
897+
def failing(x: int) -> int:
898+
raise ValueError("Tool execution failed")
899+
900+
if span_streaming:
901+
items = capture_items("span")
902+
with sentry_sdk.traces.start_span(name="mcp tx"):
903+
result = await stdio(
904+
mcp_server._lowlevel_server,
905+
method="tools/call",
906+
params={"name": "failing", "arguments": {"x": 1}},
907+
request_id="req-mcpserver-error",
908+
)
909+
sentry_sdk.flush()
910+
span = _find_mcp_span(items, method_name="tools/call")
911+
assert span is not None
912+
data = span["attributes"]
913+
else:
914+
events = capture_events()
915+
with start_transaction(name="mcp tx"):
916+
result = await stdio(
917+
mcp_server._lowlevel_server,
918+
method="tools/call",
919+
params={"name": "failing", "arguments": {"x": 1}},
920+
request_id="req-mcpserver-error",
921+
)
922+
# MCPServer also logs the unexpected exception, which is captured as an event
923+
(tx,) = (event for event in events if event.get("type") == "transaction")
924+
assert len(tx["spans"]) == 1
925+
span = tx["spans"][0]
926+
data = span["data"]
927+
928+
assert _get_response(result).result["isError"] is True
929+
if span_streaming:
930+
assert span["status"] == "ok"
931+
else:
932+
assert "status" not in span
933+
assert data[SPANDATA.ERROR_TYPE] == "tool_error"
789934

790935

791936
@pytest.mark.asyncio

0 commit comments

Comments
 (0)