From 7c5534edf2047a7d9b9cc72b761ed98de386417d Mon Sep 17 00:00:00 2001 From: Erica Pisani Date: Thu, 23 Apr 2026 15:04:42 -0400 Subject: [PATCH 1/4] feat(mcp): Migrate to span first Add span streaming support to the MCP integration. When span streaming is enabled, start spans via sentry_sdk.traces.start_span and set data via set_attribute; otherwise fall back to the existing transaction-based flow and set_data. Introduce _set_span_data_attribute in sentry_sdk.ai.utils to branch on StreamedSpan vs Span so handlers can write attributes uniformly in both modes. Refs PY-2340 Co-Authored-By: Claude Opus 4.7 --- sentry_sdk/ai/utils.py | 11 +- sentry_sdk/integrations/mcp.py | 89 +- tests/integrations/mcp/test_mcp.py | 1205 +++++++++++++++++----------- 3 files changed, 818 insertions(+), 487 deletions(-) diff --git a/sentry_sdk/ai/utils.py b/sentry_sdk/ai/utils.py index 4103736969..2fb323dc31 100644 --- a/sentry_sdk/ai/utils.py +++ b/sentry_sdk/ai/utils.py @@ -7,7 +7,7 @@ from sentry_sdk.ai.consts import DATA_URL_BASE64_REGEX if TYPE_CHECKING: - from typing import Any, Callable, Dict, List, Optional, Tuple + from typing import Any, Callable, Dict, List, Optional, Tuple, Union from sentry_sdk.tracing import Span @@ -499,6 +499,15 @@ def set_data_normalized( span.set_data(key, json.dumps(normalized)) +def _set_span_data_attribute( + span: "Union[Span, StreamedSpan]", key: str, value: "Any" +) -> None: + if isinstance(span, StreamedSpan): + span.set_attribute(key, value) + else: + span.set_data(key, value) + + def normalize_message_role(role: str) -> str: """ Normalize a message role to one of the 4 allowed gen_ai role values. diff --git a/sentry_sdk/integrations/mcp.py b/sentry_sdk/integrations/mcp.py index 58f9cd94e9..1c70fefea5 100644 --- a/sentry_sdk/integrations/mcp.py +++ b/sentry_sdk/integrations/mcp.py @@ -12,9 +12,11 @@ from typing import TYPE_CHECKING import sentry_sdk -from sentry_sdk.ai.utils import get_start_span_function +from sentry_sdk.ai.utils import _set_span_data_attribute, get_start_span_function from sentry_sdk.consts import OP, SPANDATA from sentry_sdk.integrations import Integration, DidNotEnable +from sentry_sdk.traces import StreamedSpan +from sentry_sdk.tracing_utils import has_span_streaming_enabled from sentry_sdk.utils import safe_serialize from sentry_sdk.scope import should_send_default_pii from sentry_sdk.integrations._wsgi_common import nullcontext @@ -33,8 +35,10 @@ if TYPE_CHECKING: - from typing import Any, Callable, Optional, Tuple, ContextManager + from typing import Any, Callable, Optional, Tuple, Union, ContextManager + from sentry_sdk.tracing import Span + from sentry_sdk.traces import StreamedSpan from starlette.types import Receive, Scope, Send # type: ignore[import-not-found] @@ -156,7 +160,7 @@ def _get_span_config( def _set_span_input_data( - span: "Any", + span: "Union[StreamedSpan, Span]", handler_name: str, span_data_key: str, mcp_method_name: str, @@ -168,26 +172,28 @@ def _set_span_input_data( """Set input span data for MCP handlers.""" # Set handler identifier - span.set_data(span_data_key, handler_name) - span.set_data(SPANDATA.MCP_METHOD_NAME, mcp_method_name) + _set_span_data_attribute(span, span_data_key, handler_name) + _set_span_data_attribute(span, SPANDATA.MCP_METHOD_NAME, mcp_method_name) # Set transport/MCP transport type - span.set_data( - SPANDATA.NETWORK_TRANSPORT, "pipe" if mcp_transport == "stdio" else "tcp" + _set_span_data_attribute( + span, + SPANDATA.NETWORK_TRANSPORT, + "pipe" if mcp_transport == "stdio" else "tcp", ) - span.set_data(SPANDATA.MCP_TRANSPORT, mcp_transport) + _set_span_data_attribute(span, SPANDATA.MCP_TRANSPORT, mcp_transport) # Set request_id if provided if request_id: - span.set_data(SPANDATA.MCP_REQUEST_ID, request_id) + _set_span_data_attribute(span, SPANDATA.MCP_REQUEST_ID, request_id) # Set session_id if provided if session_id: - span.set_data(SPANDATA.MCP_SESSION_ID, session_id) + _set_span_data_attribute(span, SPANDATA.MCP_SESSION_ID, session_id) # Set request arguments (excluding common request context objects) for k, v in arguments.items(): - span.set_data(f"mcp.request.argument.{k}", safe_serialize(v)) + _set_span_data_attribute(span, f"mcp.request.argument.{k}", safe_serialize(v)) def _extract_tool_result_content(result: "Any") -> "Any": @@ -231,7 +237,10 @@ def _extract_tool_result_content(result: "Any") -> "Any": def _set_span_output_data( - span: "Any", result: "Any", result_data_key: "Optional[str]", handler_type: str + span: "Union[StreamedSpan, Span]", + result: "Any", + result_data_key: "Optional[str]", + handler_type: str, ) -> None: """Set output span data for MCP handlers.""" if result is None: @@ -248,11 +257,17 @@ def _set_span_output_data( # For tools, extract the meaningful content if handler_type == "tool": extracted = _extract_tool_result_content(result) - if extracted is not None and should_include_data: - span.set_data(result_data_key, safe_serialize(extracted)) + if ( + extracted is not None + and should_include_data + and result_data_key is not None + ): + _set_span_data_attribute(span, result_data_key, safe_serialize(extracted)) # Set content count if result is a dict if isinstance(extracted, dict): - span.set_data(SPANDATA.MCP_TOOL_RESULT_CONTENT_COUNT, len(extracted)) + _set_span_data_attribute( + span, SPANDATA.MCP_TOOL_RESULT_CONTENT_COUNT, len(extracted) + ) elif handler_type == "prompt": # For prompts, count messages and set role/content only for single-message prompts try: @@ -270,7 +285,9 @@ def _set_span_output_data( # Always set message count if we found messages if message_count > 0: - span.set_data(SPANDATA.MCP_PROMPT_RESULT_MESSAGE_COUNT, message_count) + _set_span_data_attribute( + span, SPANDATA.MCP_PROMPT_RESULT_MESSAGE_COUNT, message_count + ) # Only set role and content for single-message prompts if PII is allowed if message_count == 1 and should_include_data and messages: @@ -283,7 +300,9 @@ def _set_span_output_data( role = first_message["role"] if role: - span.set_data(SPANDATA.MCP_PROMPT_RESULT_MESSAGE_ROLE, role) + _set_span_data_attribute( + span, SPANDATA.MCP_PROMPT_RESULT_MESSAGE_ROLE, role + ) # Extract content text content_text = None @@ -303,8 +322,8 @@ def _set_span_output_data( elif isinstance(msg_content, str): content_text = msg_content - if content_text: - span.set_data(result_data_key, content_text) + if content_text and result_data_key is not None: + _set_span_data_attribute(span, result_data_key, content_text) except Exception: # Silently ignore if we can't extract message info pass @@ -434,14 +453,28 @@ async def _handler_wrapper( # Get request ID, session ID, and transport from context request_id, session_id, mcp_transport = _get_request_context_data() + span_streaming = has_span_streaming_enabled(sentry_sdk.get_client().options) + # Start span and execute with isolation_scope_context: with current_scope_context: - with get_start_span_function()( - op=OP.MCP_SERVER, - name=span_name, - origin=MCPIntegration.origin, - ) as span: + span_mgr: "Union[Span, StreamedSpan]" + if span_streaming: + span_mgr = sentry_sdk.traces.start_span( + name=span_name, + attributes={ + "sentry.op": OP.MCP_SERVER, + "sentry.origin": MCPIntegration.origin, + }, + ) + else: + span_mgr = get_start_span_function()( + op=OP.MCP_SERVER, + name=span_name, + origin=MCPIntegration.origin, + ) + + with span_mgr as span: # Set input span data _set_span_input_data( span, @@ -467,7 +500,9 @@ async def _handler_wrapper( elif handler_name and "://" in handler_name: protocol = handler_name.split("://")[0] if protocol: - span.set_data(SPANDATA.MCP_RESOURCE_PROTOCOL, protocol) + _set_span_data_attribute( + span, SPANDATA.MCP_RESOURCE_PROTOCOL, protocol + ) try: # Execute the async handler @@ -481,7 +516,9 @@ async def _handler_wrapper( except Exception as e: # Set error flag for tools if handler_type == "tool": - span.set_data(SPANDATA.MCP_TOOL_RESULT_IS_ERROR, True) + _set_span_data_attribute( + span, SPANDATA.MCP_TOOL_RESULT_IS_ERROR, True + ) sentry_sdk.capture_exception(e) raise diff --git a/tests/integrations/mcp/test_mcp.py b/tests/integrations/mcp/test_mcp.py index f51d0491ae..6de4d8157b 100644 --- a/tests/integrations/mcp/test_mcp.py +++ b/tests/integrations/mcp/test_mcp.py @@ -41,6 +41,7 @@ async def __call__(self, *args, **kwargs): except ImportError: request_ctx = None +import sentry_sdk from sentry_sdk import start_transaction from sentry_sdk.consts import SPANDATA, OP from sentry_sdk.integrations.mcp import MCPIntegration @@ -52,6 +53,27 @@ async def __call__(self, *args, **kwargs): from starlette.responses import Response +def _experiments_for(span_streaming): + return {"trace_lifecycle": "stream" if span_streaming else "static"} + + +def _find_mcp_span(items, method_name=None): + """Return the first captured MCP span item payload matching method_name.""" + for item in items: + if item.type != "span": + continue + attrs = item.payload.get("attributes", {}) + if attrs.get("sentry.op") != OP.MCP_SERVER: + continue + if ( + method_name is not None + and attrs.get(SPANDATA.MCP_METHOD_NAME) != method_name + ): + continue + return item.payload + return None + + @pytest.fixture(autouse=True) def reset_request_ctx(): """Reset request context before and after each test""" @@ -97,20 +119,27 @@ def test_integration_patches_server(sentry_init): @pytest.mark.asyncio +@pytest.mark.parametrize("span_streaming", [True, False]) @pytest.mark.parametrize( "send_default_pii, include_prompts", [(True, True), (True, False), (False, True), (False, False)], ) async def test_tool_handler_stdio( - sentry_init, capture_events, send_default_pii, include_prompts, stdio + sentry_init, + capture_events, + capture_items, + send_default_pii, + include_prompts, + span_streaming, + stdio, ): """Test that synchronous tool handlers create proper spans""" sentry_init( integrations=[MCPIntegration(include_prompts=include_prompts)], traces_sample_rate=1.0, send_default_pii=send_default_pii, + _experiments=_experiments_for(span_streaming), ) - events = capture_events() server = Server("test-server") @@ -118,54 +147,81 @@ async def test_tool_handler_stdio( async def test_tool(tool_name, arguments): return {"result": "success", "value": 42} - with start_transaction(name="mcp tx"): - result = await stdio( - server, - method="tools/call", - params={ - "name": "calculate", - "arguments": {"x": 10, "y": 5}, - }, - request_id="req-123", - ) + if span_streaming: + items = capture_items("span") + with sentry_sdk.traces.start_span(name="mcp tx"): + result = await stdio( + server, + method="tools/call", + params={ + "name": "calculate", + "arguments": {"x": 10, "y": 5}, + }, + request_id="req-123", + ) + sentry_sdk.flush() + else: + events = capture_events() + with start_transaction(name="mcp tx"): + result = await stdio( + server, + method="tools/call", + params={ + "name": "calculate", + "arguments": {"x": 10, "y": 5}, + }, + request_id="req-123", + ) assert result.message.root.result["content"][0]["text"] == json.dumps( {"result": "success", "value": 42}, indent=2, ) - (tx,) = events - assert tx["type"] == "transaction" - assert len(tx["spans"]) == 1 + if span_streaming: + span = _find_mcp_span(items, method_name="tools/call") + assert span is not None + assert span["name"] == "tools/call calculate" + data = span["attributes"] + assert data["sentry.op"] == OP.MCP_SERVER + assert data["sentry.origin"] == "auto.ai.mcp" + else: + (tx,) = events + assert tx["type"] == "transaction" + assert len(tx["spans"]) == 1 - span = tx["spans"][0] - assert span["op"] == OP.MCP_SERVER - assert span["description"] == "tools/call calculate" - assert span["origin"] == "auto.ai.mcp" + span = tx["spans"][0] + assert span["op"] == OP.MCP_SERVER + assert span["description"] == "tools/call calculate" + assert span["origin"] == "auto.ai.mcp" + data = span["data"] # Check span data - assert span["data"][SPANDATA.MCP_TOOL_NAME] == "calculate" - assert span["data"][SPANDATA.MCP_METHOD_NAME] == "tools/call" - assert span["data"][SPANDATA.MCP_TRANSPORT] == "stdio" - assert span["data"][SPANDATA.MCP_REQUEST_ID] == "req-123" - assert span["data"]["mcp.request.argument.x"] == "10" - assert span["data"]["mcp.request.argument.y"] == "5" + assert data[SPANDATA.MCP_TOOL_NAME] == "calculate" + assert data[SPANDATA.MCP_METHOD_NAME] == "tools/call" + assert data[SPANDATA.MCP_TRANSPORT] == "stdio" + assert data[SPANDATA.NETWORK_TRANSPORT] == "pipe" + assert data[SPANDATA.MCP_REQUEST_ID] == "req-123" + assert SPANDATA.MCP_SESSION_ID not in data + assert data["mcp.request.argument.x"] == "10" + assert data["mcp.request.argument.y"] == "5" # Check PII-sensitive data is only present when both flags are True if send_default_pii and include_prompts: - assert span["data"][SPANDATA.MCP_TOOL_RESULT_CONTENT] == json.dumps( + assert data[SPANDATA.MCP_TOOL_RESULT_CONTENT] == json.dumps( { "result": "success", "value": 42, } ) - assert span["data"][SPANDATA.MCP_TOOL_RESULT_CONTENT_COUNT] == 2 + assert data[SPANDATA.MCP_TOOL_RESULT_CONTENT_COUNT] == 2 else: - assert SPANDATA.MCP_TOOL_RESULT_CONTENT not in span["data"] - assert SPANDATA.MCP_TOOL_RESULT_CONTENT_COUNT not in span["data"] + assert SPANDATA.MCP_TOOL_RESULT_CONTENT not in data + assert SPANDATA.MCP_TOOL_RESULT_CONTENT_COUNT not in data @pytest.mark.asyncio +@pytest.mark.parametrize("span_streaming", [True, False]) @pytest.mark.parametrize( "send_default_pii, include_prompts", [(True, True), (True, False), (False, True), (False, False)], @@ -173,8 +229,10 @@ async def test_tool(tool_name, arguments): async def test_tool_handler_streamable_http( sentry_init, capture_events, + capture_items, send_default_pii, include_prompts, + span_streaming, json_rpc, select_transactions_with_mcp_spans, ): @@ -183,8 +241,8 @@ async def test_tool_handler_streamable_http( integrations=[MCPIntegration(include_prompts=include_prompts)], traces_sample_rate=1.0, send_default_pii=send_default_pii, + _experiments=_experiments_for(span_streaming), ) - events = capture_events() server = Server("test-server") @@ -209,57 +267,89 @@ async def test_tool_async(tool_name, arguments): ) ] - session_id, result = json_rpc( - app, - method="tools/call", - params={ - "name": "process", - "arguments": { - "data": "test", + if span_streaming: + items = capture_items("span") + session_id, result = json_rpc( + app, + method="tools/call", + params={ + "name": "process", + "arguments": { + "data": "test", + }, }, - }, - request_id="req-456", - ) + request_id="req-456", + ) + sentry_sdk.flush() + else: + events = capture_events() + session_id, result = json_rpc( + app, + method="tools/call", + params={ + "name": "process", + "arguments": { + "data": "test", + }, + }, + request_id="req-456", + ) + assert result.json()["result"]["content"][0]["text"] == json.dumps( {"status": "completed"} ) - transactions = select_transactions_with_mcp_spans(events, method_name="tools/call") - assert len(transactions) == 1 - tx = transactions[0] - assert tx["type"] == "transaction" - assert len(tx["spans"]) == 1 - span = tx["spans"][0] + if span_streaming: + span = _find_mcp_span(items, method_name="tools/call") + assert span is not None + assert span["name"] == "tools/call process" + data = span["attributes"] + assert data["sentry.op"] == OP.MCP_SERVER + assert data["sentry.origin"] == "auto.ai.mcp" + else: + transactions = select_transactions_with_mcp_spans( + events, method_name="tools/call" + ) + assert len(transactions) == 1 + tx = transactions[0] + assert tx["type"] == "transaction" + assert len(tx["spans"]) == 1 + span = tx["spans"][0] - assert span["op"] == OP.MCP_SERVER - assert span["description"] == "tools/call process" - assert span["origin"] == "auto.ai.mcp" + assert span["op"] == OP.MCP_SERVER + assert span["description"] == "tools/call process" + assert span["origin"] == "auto.ai.mcp" + data = span["data"] # Check span data - assert span["data"][SPANDATA.MCP_TOOL_NAME] == "process" - assert span["data"][SPANDATA.MCP_METHOD_NAME] == "tools/call" - assert span["data"][SPANDATA.MCP_TRANSPORT] == "http" - assert span["data"][SPANDATA.MCP_REQUEST_ID] == "req-456" - assert span["data"][SPANDATA.MCP_SESSION_ID] == session_id - assert span["data"]["mcp.request.argument.data"] == "test" + assert data[SPANDATA.MCP_TOOL_NAME] == "process" + assert data[SPANDATA.MCP_METHOD_NAME] == "tools/call" + assert data[SPANDATA.MCP_TRANSPORT] == "http" + assert data[SPANDATA.NETWORK_TRANSPORT] == "tcp" + assert data[SPANDATA.MCP_REQUEST_ID] == "req-456" + assert data[SPANDATA.MCP_SESSION_ID] == session_id + assert data["mcp.request.argument.data"] == "test" # Check PII-sensitive data if send_default_pii and include_prompts: - assert span["data"][SPANDATA.MCP_TOOL_RESULT_CONTENT] == json.dumps( + assert data[SPANDATA.MCP_TOOL_RESULT_CONTENT] == json.dumps( {"status": "completed"} ) else: - assert SPANDATA.MCP_TOOL_RESULT_CONTENT not in span["data"] + assert SPANDATA.MCP_TOOL_RESULT_CONTENT not in data @pytest.mark.asyncio -async def test_tool_handler_with_error(sentry_init, capture_events, stdio): +@pytest.mark.parametrize("span_streaming", [True, False]) +async def test_tool_handler_with_error( + sentry_init, capture_events, capture_items, span_streaming, stdio +): """Test that tool handler errors are captured properly""" sentry_init( integrations=[MCPIntegration()], traces_sample_rate=1.0, + _experiments=_experiments_for(span_streaming), ) - events = capture_events() server = Server("test-server") @@ -267,56 +357,96 @@ async def test_tool_handler_with_error(sentry_init, capture_events, stdio): def failing_tool(tool_name, arguments): raise ValueError("Tool execution failed") - with start_transaction(name="mcp tx"): - result = await stdio( - server, - method="tools/call", - params={ - "name": "bad_tool", - "arguments": {}, - }, - request_id="req-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": "bad_tool", + "arguments": {}, + }, + request_id="req-error", + ) + sentry_sdk.flush() assert ( result.message.root.result["content"][0]["text"] == "Tool execution failed" ) - # Should have error event and transaction - assert len(events) == 2 - error_event, tx = events + error_payload = next(item.payload for item in items if item.type == "event") + span = _find_mcp_span(items, method_name="tools/call") + assert span is not None + + assert error_payload["level"] == "error" + assert error_payload["exception"]["values"][0]["type"] == "ValueError" + assert ( + error_payload["exception"]["values"][0]["value"] == "Tool execution failed" + ) - # Check error event - assert error_event["level"] == "error" - assert error_event["exception"]["values"][0]["type"] == "ValueError" - assert error_event["exception"]["values"][0]["value"] == "Tool execution failed" + assert span["attributes"][SPANDATA.MCP_TOOL_RESULT_IS_ERROR] is True + assert span["status"] == "error" + else: + events = capture_events() + with start_transaction(name="mcp tx"): + result = await stdio( + server, + method="tools/call", + params={ + "name": "bad_tool", + "arguments": {}, + }, + request_id="req-error", + ) - # Check transaction and span - assert tx["type"] == "transaction" - assert len(tx["spans"]) == 1 - span = tx["spans"][0] + assert ( + result.message.root.result["content"][0]["text"] + == "Tool execution failed" + ) - # Error flag should be set for tools - assert span["data"][SPANDATA.MCP_TOOL_RESULT_IS_ERROR] is True - assert span["status"] == "internal_error" - assert span["tags"]["status"] == "internal_error" + # Should have error event and transaction + assert len(events) == 2 + error_event, tx = events + + # Check error event + assert error_event["level"] == "error" + assert error_event["exception"]["values"][0]["type"] == "ValueError" + assert error_event["exception"]["values"][0]["value"] == "Tool execution failed" + + # Check transaction and span + assert tx["type"] == "transaction" + assert len(tx["spans"]) == 1 + span = tx["spans"][0] + + # Error flag should be set for tools + assert span["data"][SPANDATA.MCP_TOOL_RESULT_IS_ERROR] is True + assert span["status"] == "internal_error" + assert span["tags"]["status"] == "internal_error" @pytest.mark.asyncio +@pytest.mark.parametrize("span_streaming", [True, False]) @pytest.mark.parametrize( "send_default_pii, include_prompts", [(True, True), (True, False), (False, True), (False, False)], ) async def test_prompt_handler_stdio( - sentry_init, capture_events, send_default_pii, include_prompts, stdio + sentry_init, + capture_events, + capture_items, + send_default_pii, + include_prompts, + span_streaming, + stdio, ): """Test that synchronous prompt handlers create proper spans""" sentry_init( integrations=[MCPIntegration(include_prompts=include_prompts)], traces_sample_rate=1.0, send_default_pii=send_default_pii, + _experiments=_experiments_for(span_streaming), ) - events = capture_events() server = Server("test-server") @@ -332,16 +462,31 @@ async def test_prompt(name, arguments): ], ) - with start_transaction(name="mcp tx"): - result = await stdio( - server, - method="prompts/get", - params={ - "name": "code_help", - "arguments": {"language": "python"}, - }, - request_id="req-prompt", - ) + if span_streaming: + items = capture_items("span") + with sentry_sdk.traces.start_span(name="mcp tx"): + result = await stdio( + server, + method="prompts/get", + params={ + "name": "code_help", + "arguments": {"language": "python"}, + }, + request_id="req-prompt", + ) + sentry_sdk.flush() + else: + events = capture_events() + with start_transaction(name="mcp tx"): + result = await stdio( + server, + method="prompts/get", + params={ + "name": "code_help", + "arguments": {"language": "python"}, + }, + request_id="req-prompt", + ) assert result.message.root.result["messages"][0]["role"] == "user" assert ( @@ -349,39 +494,48 @@ async def test_prompt(name, arguments): == "Tell me about Python" ) - (tx,) = events - assert tx["type"] == "transaction" - assert len(tx["spans"]) == 1 + if span_streaming: + span = _find_mcp_span(items, method_name="prompts/get") + assert span is not None + assert span["name"] == "prompts/get code_help" + data = span["attributes"] + assert data["sentry.op"] == OP.MCP_SERVER + assert data["sentry.origin"] == "auto.ai.mcp" + else: + (tx,) = events + assert tx["type"] == "transaction" + assert len(tx["spans"]) == 1 - span = tx["spans"][0] - assert span["op"] == OP.MCP_SERVER - assert span["description"] == "prompts/get code_help" - assert span["origin"] == "auto.ai.mcp" + span = tx["spans"][0] + assert span["op"] == OP.MCP_SERVER + assert span["description"] == "prompts/get code_help" + assert span["origin"] == "auto.ai.mcp" + data = span["data"] # Check span data - assert span["data"][SPANDATA.MCP_PROMPT_NAME] == "code_help" - assert span["data"][SPANDATA.MCP_METHOD_NAME] == "prompts/get" - assert span["data"][SPANDATA.MCP_TRANSPORT] == "stdio" - assert span["data"][SPANDATA.MCP_REQUEST_ID] == "req-prompt" - assert span["data"]["mcp.request.argument.name"] == "code_help" - assert span["data"]["mcp.request.argument.language"] == "python" + assert data[SPANDATA.MCP_PROMPT_NAME] == "code_help" + assert data[SPANDATA.MCP_METHOD_NAME] == "prompts/get" + assert data[SPANDATA.MCP_TRANSPORT] == "stdio" + assert data[SPANDATA.MCP_REQUEST_ID] == "req-prompt" + assert data["mcp.request.argument.name"] == "code_help" + assert data["mcp.request.argument.language"] == "python" # Message count is always captured - assert span["data"][SPANDATA.MCP_PROMPT_RESULT_MESSAGE_COUNT] == 1 + assert data[SPANDATA.MCP_PROMPT_RESULT_MESSAGE_COUNT] == 1 # For single message prompts, role and content should be captured only with PII if send_default_pii and include_prompts: - assert span["data"][SPANDATA.MCP_PROMPT_RESULT_MESSAGE_ROLE] == "user" + assert data[SPANDATA.MCP_PROMPT_RESULT_MESSAGE_ROLE] == "user" assert ( - span["data"][SPANDATA.MCP_PROMPT_RESULT_MESSAGE_CONTENT] - == "Tell me about Python" + data[SPANDATA.MCP_PROMPT_RESULT_MESSAGE_CONTENT] == "Tell me about Python" ) else: - assert SPANDATA.MCP_PROMPT_RESULT_MESSAGE_ROLE not in span["data"] - assert SPANDATA.MCP_PROMPT_RESULT_MESSAGE_CONTENT not in span["data"] + assert SPANDATA.MCP_PROMPT_RESULT_MESSAGE_ROLE not in data + assert SPANDATA.MCP_PROMPT_RESULT_MESSAGE_CONTENT not in data @pytest.mark.asyncio +@pytest.mark.parametrize("span_streaming", [True, False]) @pytest.mark.parametrize( "send_default_pii, include_prompts", [(True, True), (True, False), (False, True), (False, False)], @@ -389,8 +543,10 @@ async def test_prompt(name, arguments): async def test_prompt_handler_streamable_http( sentry_init, capture_events, + capture_items, send_default_pii, include_prompts, + span_streaming, json_rpc, select_transactions_with_mcp_spans, ): @@ -399,8 +555,8 @@ async def test_prompt_handler_streamable_http( integrations=[MCPIntegration(include_prompts=include_prompts)], traces_sample_rate=1.0, send_default_pii=send_default_pii, + _experiments=_experiments_for(span_streaming), ) - events = capture_events() server = Server("test-server") @@ -433,47 +589,71 @@ async def test_prompt_async(name, arguments): ], ) - _, result = json_rpc( - app, - method="prompts/get", - params={ - "name": "mcp_info", - "arguments": {}, - }, - request_id="req-async-prompt", - ) + if span_streaming: + items = capture_items("span") + _, result = json_rpc( + app, + method="prompts/get", + params={ + "name": "mcp_info", + "arguments": {}, + }, + request_id="req-async-prompt", + ) + sentry_sdk.flush() + else: + events = capture_events() + _, result = json_rpc( + app, + method="prompts/get", + params={ + "name": "mcp_info", + "arguments": {}, + }, + request_id="req-async-prompt", + ) + assert len(result.json()["result"]["messages"]) == 2 - transactions = select_transactions_with_mcp_spans(events, method_name="prompts/get") - assert len(transactions) == 1 - tx = transactions[0] - assert tx["type"] == "transaction" - assert len(tx["spans"]) == 1 - span = tx["spans"][0] + if span_streaming: + span = _find_mcp_span(items, method_name="prompts/get") + assert span is not None + assert span["name"] == "prompts/get mcp_info" + data = span["attributes"] + assert data["sentry.op"] == OP.MCP_SERVER + assert data["sentry.origin"] == "auto.ai.mcp" + else: + transactions = select_transactions_with_mcp_spans( + events, method_name="prompts/get" + ) + assert len(transactions) == 1 + tx = transactions[0] + assert tx["type"] == "transaction" + assert len(tx["spans"]) == 1 + span = tx["spans"][0] - assert span["op"] == OP.MCP_SERVER - assert span["description"] == "prompts/get mcp_info" + assert span["op"] == OP.MCP_SERVER + assert span["description"] == "prompts/get mcp_info" + data = span["data"] # For multi-message prompts, count is always captured - assert span["data"][SPANDATA.MCP_PROMPT_RESULT_MESSAGE_COUNT] == 2 + assert data[SPANDATA.MCP_PROMPT_RESULT_MESSAGE_COUNT] == 2 # Role/content are never captured for multi-message prompts (even with PII) - assert ( - SPANDATA.MCP_PROMPT_RESULT_MESSAGE_ROLE not in tx["contexts"]["trace"]["data"] - ) - assert ( - SPANDATA.MCP_PROMPT_RESULT_MESSAGE_CONTENT - not in tx["contexts"]["trace"]["data"] - ) + assert SPANDATA.MCP_PROMPT_RESULT_MESSAGE_ROLE not in data + assert SPANDATA.MCP_PROMPT_RESULT_MESSAGE_CONTENT not in data @pytest.mark.asyncio -async def test_prompt_handler_with_error(sentry_init, capture_events, stdio): +@pytest.mark.parametrize("span_streaming", [True, False]) +async def test_prompt_handler_with_error( + sentry_init, capture_events, capture_items, span_streaming, stdio +): """Test that prompt handler errors are captured""" sentry_init( integrations=[MCPIntegration()], traces_sample_rate=1.0, + _experiments=_experiments_for(span_streaming), ) - events = capture_events() server = Server("test-server") @@ -481,35 +661,64 @@ async def test_prompt_handler_with_error(sentry_init, capture_events, stdio): async def failing_prompt(name, arguments): raise RuntimeError("Prompt not found") - with start_transaction(name="mcp tx"): - response = await stdio( - server, - method="prompts/get", - params={ - "name": "code_help", - "arguments": {"language": "python"}, - }, - request_id="req-error-prompt", - ) + if span_streaming: + items = capture_items("event", "span") + with sentry_sdk.traces.start_span(name="mcp tx"): + response = await stdio( + server, + method="prompts/get", + params={ + "name": "code_help", + "arguments": {"language": "python"}, + }, + request_id="req-error-prompt", + ) + sentry_sdk.flush() - assert response.message.root.error.message == "Prompt not found" + assert response.message.root.error.message == "Prompt not found" - # Should have error event and transaction - assert len(events) == 2 - error_event, tx = events + error_payload = next(item.payload for item in items if item.type == "event") + span = _find_mcp_span(items, method_name="prompts/get") + assert span is not None - assert error_event["level"] == "error" - assert error_event["exception"]["values"][0]["type"] == "RuntimeError" + assert error_payload["level"] == "error" + assert error_payload["exception"]["values"][0]["type"] == "RuntimeError" + assert span["status"] == "error" + assert SPANDATA.MCP_TOOL_RESULT_IS_ERROR not in span["attributes"] + else: + events = capture_events() + with start_transaction(name="mcp tx"): + response = await stdio( + server, + method="prompts/get", + params={ + "name": "code_help", + "arguments": {"language": "python"}, + }, + request_id="req-error-prompt", + ) + + assert response.message.root.error.message == "Prompt not found" + + # Should have error event and transaction + assert len(events) == 2 + error_event, tx = events + + assert error_event["level"] == "error" + assert error_event["exception"]["values"][0]["type"] == "RuntimeError" @pytest.mark.asyncio -async def test_resource_handler_stdio(sentry_init, capture_events, stdio): +@pytest.mark.parametrize("span_streaming", [True, False]) +async def test_resource_handler_stdio( + sentry_init, capture_events, capture_items, span_streaming, stdio +): """Test that synchronous resource handlers create proper spans""" sentry_init( integrations=[MCPIntegration()], traces_sample_rate=1.0, + _experiments=_experiments_for(span_streaming), ) - events = capture_events() server = Server("test-server") @@ -521,43 +730,69 @@ async def test_resource(uri): ) ] - with start_transaction(name="mcp tx"): - result = await stdio( - server, - method="resources/read", - params={ - "uri": "file:///path/to/file.txt", - }, - request_id="req-resource", - ) + if span_streaming: + items = capture_items("span") + with sentry_sdk.traces.start_span(name="mcp tx"): + result = await stdio( + server, + method="resources/read", + params={ + "uri": "file:///path/to/file.txt", + }, + request_id="req-resource", + ) + sentry_sdk.flush() + else: + events = capture_events() + with start_transaction(name="mcp tx"): + result = await stdio( + server, + method="resources/read", + params={ + "uri": "file:///path/to/file.txt", + }, + request_id="req-resource", + ) assert result.message.root.result["contents"][0]["text"] == json.dumps( {"content": "file contents"}, ) - (tx,) = events - assert tx["type"] == "transaction" - assert len(tx["spans"]) == 1 + if span_streaming: + span = _find_mcp_span(items, method_name="resources/read") + assert span is not None + assert span["name"] == "resources/read file:///path/to/file.txt" + data = span["attributes"] + assert data["sentry.op"] == OP.MCP_SERVER + assert data["sentry.origin"] == "auto.ai.mcp" + else: + (tx,) = events + assert tx["type"] == "transaction" + assert len(tx["spans"]) == 1 - span = tx["spans"][0] - assert span["op"] == OP.MCP_SERVER - assert span["description"] == "resources/read file:///path/to/file.txt" - assert span["origin"] == "auto.ai.mcp" + span = tx["spans"][0] + assert span["op"] == OP.MCP_SERVER + assert span["description"] == "resources/read file:///path/to/file.txt" + assert span["origin"] == "auto.ai.mcp" + data = span["data"] # Check span data - assert span["data"][SPANDATA.MCP_RESOURCE_URI] == "file:///path/to/file.txt" - assert span["data"][SPANDATA.MCP_METHOD_NAME] == "resources/read" - assert span["data"][SPANDATA.MCP_TRANSPORT] == "stdio" - assert span["data"][SPANDATA.MCP_REQUEST_ID] == "req-resource" - assert span["data"][SPANDATA.MCP_RESOURCE_PROTOCOL] == "file" + assert data[SPANDATA.MCP_RESOURCE_URI] == "file:///path/to/file.txt" + assert data[SPANDATA.MCP_METHOD_NAME] == "resources/read" + assert data[SPANDATA.MCP_TRANSPORT] == "stdio" + assert data[SPANDATA.MCP_REQUEST_ID] == "req-resource" + assert data[SPANDATA.MCP_RESOURCE_PROTOCOL] == "file" # Resources don't capture result content - assert SPANDATA.MCP_TOOL_RESULT_CONTENT not in span["data"] + assert SPANDATA.MCP_TOOL_RESULT_CONTENT not in data @pytest.mark.asyncio -async def test_resource_handler_streamble_http( +@pytest.mark.parametrize("span_streaming", [True, False]) +async def test_resource_handler_streamable_http( sentry_init, capture_events, + capture_items, + span_streaming, json_rpc, select_transactions_with_mcp_spans, ): @@ -565,8 +800,8 @@ async def test_resource_handler_streamble_http( sentry_init( integrations=[MCPIntegration()], traces_sample_rate=1.0, + _experiments=_experiments_for(span_streaming), ) - events = capture_events() server = Server("test-server") @@ -590,44 +825,69 @@ async def test_resource_async(uri): ) ] - session_id, result = json_rpc( - app, - method="resources/read", - params={ - "uri": "https://example.com/resource", - }, - request_id="req-async-resource", - ) + if span_streaming: + items = capture_items("span") + session_id, result = json_rpc( + app, + method="resources/read", + params={ + "uri": "https://example.com/resource", + }, + request_id="req-async-resource", + ) + sentry_sdk.flush() + else: + events = capture_events() + session_id, result = json_rpc( + app, + method="resources/read", + params={ + "uri": "https://example.com/resource", + }, + request_id="req-async-resource", + ) assert result.json()["result"]["contents"][0]["text"] == json.dumps( {"data": "resource data"} ) - transactions = select_transactions_with_mcp_spans( - events, method_name="resources/read" - ) - assert len(transactions) == 1 - tx = transactions[0] - assert tx["type"] == "transaction" - assert len(tx["spans"]) == 1 - span = tx["spans"][0] + if span_streaming: + span = _find_mcp_span(items, method_name="resources/read") + assert span is not None + assert span["name"] == "resources/read https://example.com/resource" + data = span["attributes"] + assert data["sentry.op"] == OP.MCP_SERVER + assert data["sentry.origin"] == "auto.ai.mcp" + else: + transactions = select_transactions_with_mcp_spans( + events, method_name="resources/read" + ) + assert len(transactions) == 1 + tx = transactions[0] + assert tx["type"] == "transaction" + assert len(tx["spans"]) == 1 + span = tx["spans"][0] - assert span["op"] == OP.MCP_SERVER - assert span["description"] == "resources/read https://example.com/resource" + assert span["op"] == OP.MCP_SERVER + assert span["description"] == "resources/read https://example.com/resource" + data = span["data"] - assert span["data"][SPANDATA.MCP_RESOURCE_URI] == "https://example.com/resource" - assert span["data"][SPANDATA.MCP_RESOURCE_PROTOCOL] == "https" - assert span["data"][SPANDATA.MCP_SESSION_ID] == session_id + assert data[SPANDATA.MCP_RESOURCE_URI] == "https://example.com/resource" + assert data[SPANDATA.MCP_RESOURCE_PROTOCOL] == "https" + assert data[SPANDATA.MCP_SESSION_ID] == session_id @pytest.mark.asyncio -async def test_resource_handler_with_error(sentry_init, capture_events, stdio): +@pytest.mark.parametrize("span_streaming", [True, False]) +async def test_resource_handler_with_error( + sentry_init, capture_events, capture_items, span_streaming, stdio +): """Test that resource handler errors are captured""" sentry_init( integrations=[MCPIntegration()], traces_sample_rate=1.0, + _experiments=_experiments_for(span_streaming), ) - events = capture_events() server = Server("test-server") @@ -635,39 +895,69 @@ async def test_resource_handler_with_error(sentry_init, capture_events, stdio): def failing_resource(uri): raise FileNotFoundError("Resource not found") - with start_transaction(name="mcp tx"): - await stdio( - server, - method="resources/read", - params={ - "uri": "file:///missing.txt", - }, - request_id="req-error-resource", - ) + if span_streaming: + items = capture_items("event", "span") + with sentry_sdk.traces.start_span(name="mcp tx"): + await stdio( + server, + method="resources/read", + params={ + "uri": "file:///missing.txt", + }, + request_id="req-error-resource", + ) + sentry_sdk.flush() - # Should have error event and transaction - assert len(events) == 2 - error_event, tx = events + error_payload = next(item.payload for item in items if item.type == "event") + span = _find_mcp_span(items, method_name="resources/read") + assert span is not None - assert error_event["level"] == "error" - assert error_event["exception"]["values"][0]["type"] == "FileNotFoundError" + assert error_payload["level"] == "error" + assert error_payload["exception"]["values"][0]["type"] == "FileNotFoundError" + assert span["status"] == "error" + assert SPANDATA.MCP_TOOL_RESULT_IS_ERROR not in span["attributes"] + else: + events = capture_events() + with start_transaction(name="mcp tx"): + await stdio( + server, + method="resources/read", + params={ + "uri": "file:///missing.txt", + }, + request_id="req-error-resource", + ) + + # Should have error event and transaction + assert len(events) == 2 + error_event, tx = events + + assert error_event["level"] == "error" + assert error_event["exception"]["values"][0]["type"] == "FileNotFoundError" @pytest.mark.asyncio +@pytest.mark.parametrize("span_streaming", [True, False]) @pytest.mark.parametrize( "send_default_pii, include_prompts", [(True, True), (False, False)], ) async def test_tool_result_extraction_tuple( - sentry_init, capture_events, send_default_pii, include_prompts, stdio + sentry_init, + capture_events, + capture_items, + send_default_pii, + include_prompts, + span_streaming, + stdio, ): """Test extraction of tool results from tuple format (UnstructuredContent, StructuredContent)""" sentry_init( integrations=[MCPIntegration(include_prompts=include_prompts)], traces_sample_rate=1.0, send_default_pii=send_default_pii, + _experiments=_experiments_for(span_streaming), ) - events = capture_events() server = Server("test-server") @@ -678,49 +968,75 @@ def test_tool_tuple(tool_name, arguments): structured = {"key": "value", "count": 5} return (unstructured, structured) - with start_transaction(name="mcp tx"): - await stdio( - server, - method="tools/call", - params={ - "name": "calculate", - "arguments": {}, - }, - request_id="req-tuple", - ) + if span_streaming: + items = capture_items("span") + with sentry_sdk.traces.start_span(name="mcp tx"): + await stdio( + server, + method="tools/call", + params={ + "name": "calculate", + "arguments": {}, + }, + request_id="req-tuple", + ) + sentry_sdk.flush() - (tx,) = events - span = tx["spans"][0] + 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"): + await stdio( + server, + method="tools/call", + params={ + "name": "calculate", + "arguments": {}, + }, + request_id="req-tuple", + ) + + (tx,) = events + data = tx["spans"][0]["data"] # Should extract the structured content (second element of tuple) only with PII if send_default_pii and include_prompts: - assert span["data"][SPANDATA.MCP_TOOL_RESULT_CONTENT] == json.dumps( + assert data[SPANDATA.MCP_TOOL_RESULT_CONTENT] == json.dumps( { "key": "value", "count": 5, } ) - assert span["data"][SPANDATA.MCP_TOOL_RESULT_CONTENT_COUNT] == 2 + assert data[SPANDATA.MCP_TOOL_RESULT_CONTENT_COUNT] == 2 else: - assert SPANDATA.MCP_TOOL_RESULT_CONTENT not in span["data"] - assert SPANDATA.MCP_TOOL_RESULT_CONTENT_COUNT not in span["data"] + assert SPANDATA.MCP_TOOL_RESULT_CONTENT not in data + assert SPANDATA.MCP_TOOL_RESULT_CONTENT_COUNT not in data @pytest.mark.asyncio +@pytest.mark.parametrize("span_streaming", [True, False]) @pytest.mark.parametrize( "send_default_pii, include_prompts", [(True, True), (False, False)], ) async def test_tool_result_extraction_unstructured( - sentry_init, capture_events, send_default_pii, include_prompts, stdio + sentry_init, + capture_events, + capture_items, + send_default_pii, + include_prompts, + span_streaming, + stdio, ): """Test extraction of tool results from UnstructuredContent (list of content blocks)""" sentry_init( integrations=[MCPIntegration(include_prompts=include_prompts)], traces_sample_rate=1.0, send_default_pii=send_default_pii, + _experiments=_experiments_for(span_streaming), ) - events = capture_events() server = Server("test-server") @@ -732,69 +1048,57 @@ def test_tool_unstructured(tool_name, arguments): MockTextContent("Second part"), ] - with start_transaction(name="mcp tx"): - await stdio( - server, - method="tools/call", - params={ - "name": "text_tool", - "arguments": {}, - }, - request_id="req-unstructured", - ) + if span_streaming: + items = capture_items("span") + with sentry_sdk.traces.start_span(name="mcp tx"): + await stdio( + server, + method="tools/call", + params={ + "name": "text_tool", + "arguments": {}, + }, + request_id="req-unstructured", + ) + 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"): + await stdio( + server, + method="tools/call", + params={ + "name": "text_tool", + "arguments": {}, + }, + request_id="req-unstructured", + ) - (tx,) = events - span = tx["spans"][0] + (tx,) = events + data = tx["spans"][0]["data"] # Should extract and join text from content blocks only with PII if send_default_pii and include_prompts: - assert ( - span["data"][SPANDATA.MCP_TOOL_RESULT_CONTENT] == "First part Second part" - ) + assert data[SPANDATA.MCP_TOOL_RESULT_CONTENT] == "First part Second part" else: - assert SPANDATA.MCP_TOOL_RESULT_CONTENT not in span["data"] - - -@pytest.mark.asyncio -async def test_span_origin(sentry_init, capture_events, stdio): - """Test that span origin is set correctly""" - sentry_init( - integrations=[MCPIntegration()], - traces_sample_rate=1.0, - ) - events = capture_events() - - server = Server("test-server") - - @server.call_tool() - def test_tool(tool_name, arguments): - return {"result": "test"} - - with start_transaction(name="mcp tx"): - await stdio( - server, - method="tools/call", - params={ - "name": "calculate", - "arguments": {"x": 10, "y": 5}, - }, - request_id="req-origin", - ) - - (tx,) = events - - assert tx["contexts"]["trace"]["origin"] == "manual" - assert tx["spans"][0]["origin"] == "auto.ai.mcp" + assert SPANDATA.MCP_TOOL_RESULT_CONTENT not in data @pytest.mark.asyncio -async def test_multiple_handlers(sentry_init, capture_events, stdio): +@pytest.mark.parametrize("span_streaming", [True, False]) +async def test_multiple_handlers( + sentry_init, capture_events, capture_items, span_streaming, stdio +): """Test that multiple handler calls create multiple spans""" sentry_init( integrations=[MCPIntegration()], traces_sample_rate=1.0, + _experiments=_experiments_for(span_streaming), ) - events = capture_events() server = Server("test-server") @@ -817,7 +1121,14 @@ def prompt1(name, arguments): ], ) - with start_transaction(name="mcp tx"): + if span_streaming: + items = capture_items("span") + tx_ctx = sentry_sdk.traces.start_span(name="mcp tx") + else: + events = capture_events() + tx_ctx = start_transaction(name="mcp tx") + + with tx_ctx: await stdio( server, method="tools/call", @@ -848,35 +1159,56 @@ def prompt1(name, arguments): request_id="req-multi", ) - (tx,) = events - assert tx["type"] == "transaction" - assert len(tx["spans"]) == 3 + if span_streaming: + sentry_sdk.flush() + mcp_spans = [ + item.payload + for item in items + if item.type == "span" + and item.payload.get("attributes", {}).get("sentry.op") == OP.MCP_SERVER + ] + assert len(mcp_spans) == 3 + assert all(s["attributes"]["sentry.op"] == OP.MCP_SERVER for s in mcp_spans) + span_names = [s["name"] for s in mcp_spans] + assert "tools/call tool_a" in span_names + assert "tools/call tool_b" in span_names + assert "prompts/get prompt_a" in span_names + else: + (tx,) = events + assert tx["type"] == "transaction" + assert len(tx["spans"]) == 3 - # Check that we have different span types - span_ops = [span["op"] for span in tx["spans"]] - assert all(op == OP.MCP_SERVER for op in span_ops) + span_ops = [span["op"] for span in tx["spans"]] + assert all(op == OP.MCP_SERVER for op in span_ops) - span_descriptions = [span["description"] for span in tx["spans"]] - assert "tools/call tool_a" in span_descriptions - assert "tools/call tool_b" in span_descriptions - assert "prompts/get prompt_a" in span_descriptions + span_descriptions = [span["description"] for span in tx["spans"]] + assert "tools/call tool_a" in span_descriptions + assert "tools/call tool_b" in span_descriptions + assert "prompts/get prompt_a" in span_descriptions @pytest.mark.asyncio +@pytest.mark.parametrize("span_streaming", [True, False]) @pytest.mark.parametrize( "send_default_pii, include_prompts", [(True, True), (False, False)], ) async def test_prompt_with_dict_result( - sentry_init, capture_events, send_default_pii, include_prompts, stdio + sentry_init, + capture_events, + capture_items, + send_default_pii, + include_prompts, + span_streaming, + stdio, ): """Test prompt handler with dict result instead of GetPromptResult object""" sentry_init( integrations=[MCPIntegration(include_prompts=include_prompts)], traces_sample_rate=1.0, send_default_pii=send_default_pii, + _experiments=_experiments_for(span_streaming), ) - events = capture_events() server = Server("test-server") @@ -889,43 +1221,62 @@ def test_prompt_dict(name, arguments): ] } - with start_transaction(name="mcp tx"): - await stdio( - server, - method="prompts/get", - params={ - "name": "dict_prompt", - "arguments": {}, - }, - request_id="req-dict-prompt", - ) + if span_streaming: + items = capture_items("span") + with sentry_sdk.traces.start_span(name="mcp tx"): + await stdio( + server, + method="prompts/get", + params={ + "name": "dict_prompt", + "arguments": {}, + }, + request_id="req-dict-prompt", + ) + sentry_sdk.flush() + + span = _find_mcp_span(items, method_name="prompts/get") + assert span is not None + data = span["attributes"] + else: + events = capture_events() + with start_transaction(name="mcp tx"): + await stdio( + server, + method="prompts/get", + params={ + "name": "dict_prompt", + "arguments": {}, + }, + request_id="req-dict-prompt", + ) - (tx,) = events - span = tx["spans"][0] + (tx,) = events + data = tx["spans"][0]["data"] # Message count is always captured - assert span["data"][SPANDATA.MCP_PROMPT_RESULT_MESSAGE_COUNT] == 1 + assert data[SPANDATA.MCP_PROMPT_RESULT_MESSAGE_COUNT] == 1 # Role and content only captured with PII if send_default_pii and include_prompts: - assert span["data"][SPANDATA.MCP_PROMPT_RESULT_MESSAGE_ROLE] == "user" - assert ( - span["data"][SPANDATA.MCP_PROMPT_RESULT_MESSAGE_CONTENT] - == "Hello from dict" - ) + assert data[SPANDATA.MCP_PROMPT_RESULT_MESSAGE_ROLE] == "user" + assert data[SPANDATA.MCP_PROMPT_RESULT_MESSAGE_CONTENT] == "Hello from dict" else: - assert SPANDATA.MCP_PROMPT_RESULT_MESSAGE_ROLE not in span["data"] - assert SPANDATA.MCP_PROMPT_RESULT_MESSAGE_CONTENT not in span["data"] + assert SPANDATA.MCP_PROMPT_RESULT_MESSAGE_ROLE not in data + assert SPANDATA.MCP_PROMPT_RESULT_MESSAGE_CONTENT not in data @pytest.mark.asyncio -async def test_tool_with_complex_arguments(sentry_init, capture_events, stdio): +@pytest.mark.parametrize("span_streaming", [True, False]) +async def test_tool_with_complex_arguments( + sentry_init, capture_events, capture_items, span_streaming, stdio +): """Test tool handler with complex nested arguments""" sentry_init( integrations=[MCPIntegration()], traces_sample_rate=1.0, + _experiments=_experiments_for(span_streaming), ) - events = capture_events() server = Server("test-server") @@ -933,41 +1284,64 @@ async def test_tool_with_complex_arguments(sentry_init, capture_events, stdio): def test_tool_complex(tool_name, arguments): return {"processed": True} - with start_transaction(name="mcp tx"): - complex_args = { - "nested": {"key": "value", "list": [1, 2, 3]}, - "string": "test", - "number": 42, - } - await stdio( - server, - method="tools/call", - params={ - "name": "complex_tool", - "arguments": complex_args, - }, - request_id="req-complex", - ) + complex_args = { + "nested": {"key": "value", "list": [1, 2, 3]}, + "string": "test", + "number": 42, + } + + if span_streaming: + items = capture_items("span") + with sentry_sdk.traces.start_span(name="mcp tx"): + await stdio( + server, + method="tools/call", + params={ + "name": "complex_tool", + "arguments": complex_args, + }, + request_id="req-complex", + ) + sentry_sdk.flush() - (tx,) = events - span = tx["spans"][0] + 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"): + await stdio( + server, + method="tools/call", + params={ + "name": "complex_tool", + "arguments": complex_args, + }, + request_id="req-complex", + ) + + (tx,) = events + data = tx["spans"][0]["data"] # Complex arguments should be serialized - assert span["data"]["mcp.request.argument.nested"] == json.dumps( + assert data["mcp.request.argument.nested"] == json.dumps( {"key": "value", "list": [1, 2, 3]} ) - assert span["data"]["mcp.request.argument.string"] == "test" - assert span["data"]["mcp.request.argument.number"] == "42" + assert data["mcp.request.argument.string"] == "test" + assert data["mcp.request.argument.number"] == "42" @pytest.mark.asyncio -async def test_sse_transport_detection(sentry_init, capture_events, json_rpc_sse): +@pytest.mark.parametrize("span_streaming", [True, False]) +async def test_sse_transport_detection( + sentry_init, capture_events, capture_items, span_streaming, json_rpc_sse +): """Test that SSE transport is correctly detected via query parameter""" sentry_init( integrations=[MCPIntegration()], traces_sample_rate=1.0, + _experiments=_experiments_for(span_streaming), ) - events = capture_events() server = Server("test-server") sse = SseServerTransport("/messages/") @@ -1001,6 +1375,11 @@ async def run_server(): async def test_tool(tool_name, arguments): return {"result": "success"} + if span_streaming: + items = capture_items("span") + else: + events = capture_events() + keep_sse_alive = asyncio.Event() app_task, session_id, result = await json_rpc_sse( app, @@ -1018,116 +1397,22 @@ async def test_tool(tool_name, arguments): assert result["result"]["structuredContent"] == {"result": "success"} - transactions = [ - event - for event in events - if event["type"] == "transaction" and event["transaction"] == "/sse" - ] - assert len(transactions) == 1 - tx = transactions[0] - span = tx["spans"][0] - - # Check that SSE transport is detected - assert span["data"][SPANDATA.MCP_TRANSPORT] == "sse" - assert span["data"][SPANDATA.NETWORK_TRANSPORT] == "tcp" - assert span["data"][SPANDATA.MCP_SESSION_ID] == session_id - - -def test_streamable_http_transport_detection( - sentry_init, - capture_events, - json_rpc, - select_transactions_with_mcp_spans, -): - """Test that StreamableHTTP transport is correctly detected via header""" - sentry_init( - integrations=[MCPIntegration()], - traces_sample_rate=1.0, - ) - events = capture_events() - - server = Server("test-server") - - session_manager = StreamableHTTPSessionManager( - app=server, - json_response=True, - ) - - app = Starlette( - routes=[ - Mount("/mcp", app=session_manager.handle_request), - ], - lifespan=lambda app: session_manager.run(), - ) - - @server.call_tool() - async def test_tool(tool_name, arguments): - return [ - TextContent( - type="text", - text=json.dumps({"status": "success"}), - ) + if span_streaming: + sentry_sdk.flush() + span = _find_mcp_span(items, method_name="tools/call") + assert span is not None + data = span["attributes"] + else: + transactions = [ + event + for event in events + if event["type"] == "transaction" and event["transaction"] == "/sse" ] + assert len(transactions) == 1 + tx = transactions[0] + data = tx["spans"][0]["data"] - session_id, result = json_rpc( - app, - method="tools/call", - params={ - "name": "http_tool", - "arguments": {}, - }, - request_id="req-http", - ) - assert result.json()["result"]["content"][0]["text"] == json.dumps( - {"status": "success"} - ) - - transactions = select_transactions_with_mcp_spans(events, method_name="tools/call") - assert len(transactions) == 1 - tx = transactions[0] - assert tx["type"] == "transaction" - assert len(tx["spans"]) == 1 - span = tx["spans"][0] - - # Check that HTTP transport is detected - assert span["data"][SPANDATA.MCP_TRANSPORT] == "http" - assert span["data"][SPANDATA.NETWORK_TRANSPORT] == "tcp" - assert span["data"][SPANDATA.MCP_SESSION_ID] == session_id - - -@pytest.mark.asyncio -async def test_stdio_transport_detection(sentry_init, capture_events, stdio): - """Test that stdio transport is correctly detected when no HTTP request""" - sentry_init( - integrations=[MCPIntegration()], - traces_sample_rate=1.0, - ) - events = capture_events() - - server = Server("test-server") - - @server.call_tool() - async def test_tool(tool_name, arguments): - return {"result": "success"} - - with start_transaction(name="mcp tx"): - result = await stdio( - server, - method="tools/call", - params={ - "name": "stdio_tool", - "arguments": {}, - }, - request_id="req-stdio", - ) - - assert result.message.root.result["structuredContent"] == {"result": "success"} - - (tx,) = events - span = tx["spans"][0] - - # Check that stdio transport is detected - assert span["data"][SPANDATA.MCP_TRANSPORT] == "stdio" - assert span["data"][SPANDATA.NETWORK_TRANSPORT] == "pipe" - # No session ID for stdio transport - assert SPANDATA.MCP_SESSION_ID not in span["data"] + # Check that SSE transport is detected + assert data[SPANDATA.MCP_TRANSPORT] == "sse" + assert data[SPANDATA.NETWORK_TRANSPORT] == "tcp" + assert data[SPANDATA.MCP_SESSION_ID] == session_id From 925c6a9d69297c6718560fabab84b041183b1d76 Mon Sep 17 00:00:00 2001 From: Erica Pisani Date: Thu, 23 Apr 2026 15:21:17 -0400 Subject: [PATCH 2/4] Empty commit to re-trigger bots and workflow. There is no duplication of as seen when the file is viewed, but for some reason this is being flagged From 2b6fb52dd7c1da3e63bbd2f092dfa50ece9e6464 Mon Sep 17 00:00:00 2001 From: Erica Pisani Date: Fri, 24 Apr 2026 08:33:32 -0400 Subject: [PATCH 3/4] Remove duplicate function --- sentry_sdk/ai/utils.py | 9 --------- 1 file changed, 9 deletions(-) diff --git a/sentry_sdk/ai/utils.py b/sentry_sdk/ai/utils.py index d6908a35b1..8efa077ce5 100644 --- a/sentry_sdk/ai/utils.py +++ b/sentry_sdk/ai/utils.py @@ -511,15 +511,6 @@ def _set_span_data_attribute( span.set_data(key, value) -def _set_span_data_attribute( - span: "Union[Span, StreamedSpan]", key: str, value: "Any" -) -> None: - if isinstance(span, StreamedSpan): - span.set_attribute(key, value) - else: - span.set_data(key, value) - - def normalize_message_role(role: str) -> str: """ Normalize a message role to one of the 4 allowed gen_ai role values. From ca90834dbce19c42d87cfb780c3f414d67df5180 Mon Sep 17 00:00:00 2001 From: Erica Pisani Date: Fri, 24 Apr 2026 09:49:18 -0400 Subject: [PATCH 4/4] test(mcp): Assert span status in non-streaming error paths The non-streaming branches of test_prompt_handler_with_error and test_resource_handler_with_error only verified error-event fields, leaving span status and transaction shape unchecked. Mirror the streaming-branch assertions and the tool-handler test pattern so regressions in classic-mode error handling for prompts and resources are caught. Co-Authored-By: Claude Opus 4.7 --- tests/integrations/mcp/test_mcp.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/tests/integrations/mcp/test_mcp.py b/tests/integrations/mcp/test_mcp.py index 6de4d8157b..d41152036f 100644 --- a/tests/integrations/mcp/test_mcp.py +++ b/tests/integrations/mcp/test_mcp.py @@ -707,6 +707,14 @@ async def failing_prompt(name, arguments): assert error_event["level"] == "error" assert error_event["exception"]["values"][0]["type"] == "RuntimeError" + # Check transaction and span + assert tx["type"] == "transaction" + assert len(tx["spans"]) == 1 + span = tx["spans"][0] + + assert span["status"] == "internal_error" + assert SPANDATA.MCP_TOOL_RESULT_IS_ERROR not in span["data"] + @pytest.mark.asyncio @pytest.mark.parametrize("span_streaming", [True, False]) @@ -935,6 +943,14 @@ def failing_resource(uri): assert error_event["level"] == "error" assert error_event["exception"]["values"][0]["type"] == "FileNotFoundError" + # Check transaction and span + assert tx["type"] == "transaction" + assert len(tx["spans"]) == 1 + span = tx["spans"][0] + + assert span["status"] == "internal_error" + assert SPANDATA.MCP_TOOL_RESULT_IS_ERROR not in span["data"] + @pytest.mark.asyncio @pytest.mark.parametrize("span_streaming", [True, False])