diff --git a/sentry_sdk/integrations/mcp.py b/sentry_sdk/integrations/mcp.py index b4482112e1..1d0bb6927f 100644 --- a/sentry_sdk/integrations/mcp.py +++ b/sentry_sdk/integrations/mcp.py @@ -14,12 +14,11 @@ from typing import TYPE_CHECKING import sentry_sdk -from sentry_sdk.ai.utils import _set_span_data_attribute, get_start_span_function +from sentry_sdk.ai.utils import _set_span_data_attribute from sentry_sdk.consts import OP, SPANDATA from sentry_sdk.integrations import DidNotEnable, Integration, _check_minimum_version from sentry_sdk.scope import should_send_default_pii from sentry_sdk.traces import StreamedSpan -from sentry_sdk.tracing_utils import has_span_streaming_enabled from sentry_sdk.utils import ( capture_internal_exceptions, event_from_exception, @@ -318,79 +317,66 @@ async def _tool_handler_wrapper( # Get request ID, session ID, and transport from context request_id, session_id, mcp_transport = _get_request_context_data(ctx=ctx) - span_streaming = has_span_streaming_enabled(sentry_sdk.get_client().options) - # Start span and execute - with _active_http_scopes(ctx=ctx): - span_mgr: "Union[Span, StreamedSpan]" - if span_streaming: - span_mgr = sentry_sdk.traces.start_span( - name=f"tools/call {handler_name}", - attributes={ - "sentry.op": OP.MCP_SERVER, - "sentry.origin": MCPIntegration.origin, - }, - ) - else: - span_mgr = get_start_span_function()( - op=OP.MCP_SERVER, - name=f"tools/call {handler_name}", - origin=MCPIntegration.origin, - ) + with _active_http_scopes(ctx=ctx), sentry_sdk.traces.start_span( + name=f"tools/call {handler_name}", + attributes={ + "sentry.op": OP.MCP_SERVER, + "sentry.origin": MCPIntegration.origin, + }, + ) as span: + # Set input span data + _set_span_input_data( + span, + handler_name, + SPANDATA.MCP_TOOL_NAME, + "tools/call", + arguments, + request_id, + session_id, + mcp_transport, + ) - with span_mgr as span: - # Set input span data - _set_span_input_data( - span, - handler_name, - SPANDATA.MCP_TOOL_NAME, - "tools/call", - arguments, - request_id, - session_id, - mcp_transport, - ) + try: + # Execute the async handler + if self is not None: + original_args = (self, *original_args) + + result = func(*original_args, **original_kwargs) + if force_await or inspect.isawaitable(result): + result = await result + + except Exception as e: + with capture_internal_exceptions(): + _capture_exception(e) + raise + + if result is None: + return result - try: - # Execute the async handler - if self is not None: - original_args = (self, *original_args) - - result = func(*original_args, **original_kwargs) - if force_await or inspect.isawaitable(result): - result = await result - - except Exception as e: - with capture_internal_exceptions(): - _capture_exception(e) - raise - - if result is None: - return result - - # Get integration to check PII settings - integration = client.get_integration(MCPIntegration) - if integration is None: - return result - - # Check if we should include sensitive data - should_include_data = False - if has_data_collection_enabled(client.options): - if client.options["data_collection"]["gen_ai"]["outputs"]: - should_include_data = True - elif should_send_default_pii() and integration.include_prompts: + # Get integration to check PII settings + integration = client.get_integration(MCPIntegration) + if integration is None: + return result + + # Check if we should include sensitive data + should_include_data = False + if has_data_collection_enabled(client.options): + if client.options["data_collection"]["gen_ai"]["outputs"]: should_include_data = True + elif should_send_default_pii() and integration.include_prompts: + should_include_data = True - extracted = _extract_tool_result_content(result) - if extracted is not None and should_include_data: + extracted = _extract_tool_result_content(result) + if extracted is not None and should_include_data: + _set_span_data_attribute( + span, SPANDATA.MCP_TOOL_RESULT_CONTENT, safe_serialize(extracted) + ) + # Set content count if result is a dict + if isinstance(extracted, dict): _set_span_data_attribute( - span, SPANDATA.MCP_TOOL_RESULT_CONTENT, safe_serialize(extracted) + span, SPANDATA.MCP_TOOL_RESULT_CONTENT_COUNT, len(extracted) ) - # Set content count if result is a dict - if isinstance(extracted, dict): - _set_span_data_attribute( - span, SPANDATA.MCP_TOOL_RESULT_CONTENT_COUNT, len(extracted) - ) return result @@ -421,82 +407,68 @@ async def _instrument_v2_tool_call( # Get request ID, session ID, and transport from context request_id, session_id, mcp_transport = _get_request_context_data(ctx=ctx) - span_streaming = has_span_streaming_enabled(client.options) - # Start span and execute - with _active_http_scopes(ctx=ctx): - span_mgr: "Union[Span, StreamedSpan]" - if span_streaming: - span_mgr = sentry_sdk.traces.start_span( - name=f"tools/call {handler_name}", - attributes={ - "sentry.op": OP.MCP_SERVER, - "sentry.origin": MCPIntegration.origin, - }, - ) - else: - span_mgr = get_start_span_function()( - op=OP.MCP_SERVER, - name=f"tools/call {handler_name}", - origin=MCPIntegration.origin, - ) - - with span_mgr as span: - # Set input span data - _set_span_input_data( - span, - handler_name, - SPANDATA.MCP_TOOL_NAME, - "tools/call", - arguments, - request_id, - session_id, - mcp_transport, - ) - - try: - result = await call_next(ctx) - - except Exception as e: - with capture_internal_exceptions(): - _capture_exception(e) - raise - - if not isinstance(result, dict): - return result - - # Get integration to check PII settings - integration = client.get_integration(MCPIntegration) - if integration is None: - return result + with _active_http_scopes(ctx=ctx), sentry_sdk.traces.start_span( + name=f"tools/call {handler_name}", + attributes={ + "sentry.op": OP.MCP_SERVER, + "sentry.origin": MCPIntegration.origin, + }, + ) as span: + # Set input span data + _set_span_input_data( + span, + handler_name, + SPANDATA.MCP_TOOL_NAME, + "tools/call", + arguments, + request_id, + session_id, + mcp_transport, + ) - # Check if we should include sensitive data - should_include_result_data = False - if has_data_collection_enabled(client.options): - if client.options["data_collection"]["gen_ai"]["outputs"]: - should_include_result_data = True - elif should_send_default_pii() and integration.include_prompts: + try: + result = await call_next(ctx) + except Exception as e: + with capture_internal_exceptions(): + _capture_exception(e) + raise + + if not isinstance(result, dict): + return result + + # Get integration to check PII settings + integration = client.get_integration(MCPIntegration) + if integration is None: + return result + + # Check if we should include sensitive data + should_include_result_data = False + if has_data_collection_enabled(client.options): + if client.options["data_collection"]["gen_ai"]["outputs"]: should_include_result_data = True + elif should_send_default_pii() and integration.include_prompts: + should_include_result_data = True - result_content = result - if "structuredContent" in result: - result_content = result["structuredContent"] - elif isinstance(result.get("content"), list): - result_content = _extract_text_from_content_blocks(result["content"]) + result_content = result + if "structuredContent" in result: + result_content = result["structuredContent"] + elif isinstance(result.get("content"), list): + result_content = _extract_text_from_content_blocks(result["content"]) - if result_content is not None and should_include_result_data: + if result_content is not None and should_include_result_data: + _set_span_data_attribute( + span, + SPANDATA.MCP_TOOL_RESULT_CONTENT, + safe_serialize(result_content), + ) + # Set content count if result is a dict + if isinstance(result_content, dict): _set_span_data_attribute( span, - SPANDATA.MCP_TOOL_RESULT_CONTENT, - safe_serialize(result_content), + SPANDATA.MCP_TOOL_RESULT_CONTENT_COUNT, + len(result_content), ) - # Set content count if result is a dict - if isinstance(result_content, dict): - _set_span_data_attribute( - span, - SPANDATA.MCP_TOOL_RESULT_CONTENT_COUNT, - len(result_content), - ) return result @@ -548,131 +520,118 @@ async def _prompt_handler_wrapper( # Get request ID, session ID, and transport from context request_id, session_id, mcp_transport = _get_request_context_data(ctx=ctx) - span_streaming = has_span_streaming_enabled(client.options) - # Start span and execute - with _active_http_scopes(ctx=ctx): - span_mgr: "Union[Span, StreamedSpan]" - if span_streaming: - span_mgr = sentry_sdk.traces.start_span( - name=f"prompts/get {handler_name}", - attributes={ - "sentry.op": OP.MCP_SERVER, - "sentry.origin": MCPIntegration.origin, - }, - ) - else: - span_mgr = get_start_span_function()( - op=OP.MCP_SERVER, - name=f"prompts/get {handler_name}", - origin=MCPIntegration.origin, - ) + with _active_http_scopes(ctx=ctx), sentry_sdk.traces.start_span( + name=f"prompts/get {handler_name}", + attributes={ + "sentry.op": OP.MCP_SERVER, + "sentry.origin": MCPIntegration.origin, + }, + ) as span: + # Set input span data + _set_span_input_data( + span, + handler_name, + SPANDATA.MCP_PROMPT_NAME, + "prompts/get", + arguments, + request_id, + session_id, + mcp_transport, + ) - with span_mgr as span: - # Set input span data - _set_span_input_data( - span, - handler_name, - SPANDATA.MCP_PROMPT_NAME, - "prompts/get", - arguments, - request_id, - session_id, - mcp_transport, - ) + try: + # Execute the async handler + if self is not None: + original_args = (self, *original_args) + + result = func(*original_args, **original_kwargs) + if force_await or inspect.isawaitable(result): + result = await result + + except Exception as e: + with capture_internal_exceptions(): + _capture_exception(e) + raise - try: - # Execute the async handler - if self is not None: - original_args = (self, *original_args) - - result = func(*original_args, **original_kwargs) - if force_await or inspect.isawaitable(result): - result = await result - - except Exception as e: - with capture_internal_exceptions(): - _capture_exception(e) - raise - - if result is None: - return result - - # Get integration to check PII settings - integration = client.get_integration(MCPIntegration) - if integration is None: - return result - - # Check if we should include sensitive data - should_include_result_data = False - if has_data_collection_enabled(client.options): - if client.options["data_collection"]["gen_ai"]["inputs"]: - should_include_result_data = True - elif should_send_default_pii() and integration.include_prompts: + if result is None: + return result + + # Get integration to check PII settings + integration = client.get_integration(MCPIntegration) + if integration is None: + return result + + # Check if we should include sensitive data + should_include_result_data = False + if has_data_collection_enabled(client.options): + if client.options["data_collection"]["gen_ai"]["inputs"]: should_include_result_data = True + elif should_send_default_pii() and integration.include_prompts: + should_include_result_data = True + + # For prompts, count messages and set role/content only for single-message prompts + try: + messages: "Optional[list[str]]" = None + message_count = 0 + + # Check if result has messages attribute (GetPromptResult) + if hasattr(result, "messages") and result.messages: + messages = result.messages # type: ignore[assignment] + message_count = len(messages) # type: ignore[arg-type] + # Also check if result is a dict with messages + elif isinstance(result, dict) and result.get("messages"): + messages = result["messages"] + message_count = len(messages) + + # Always set message count if we found messages + if message_count > 0: + _set_span_data_attribute( + span, SPANDATA.MCP_PROMPT_RESULT_MESSAGE_COUNT, message_count + ) - # For prompts, count messages and set role/content only for single-message prompts - try: - messages: "Optional[list[str]]" = None - message_count = 0 - - # Check if result has messages attribute (GetPromptResult) - if hasattr(result, "messages") and result.messages: - messages = result.messages # type: ignore[assignment] - message_count = len(messages) # type: ignore[arg-type] - # Also check if result is a dict with messages - elif isinstance(result, dict) and result.get("messages"): - messages = result["messages"] - message_count = len(messages) - - # Always set message count if we found messages - if message_count > 0: + # Only set role and content for single-message prompts if PII is allowed + if message_count == 1 and should_include_result_data and messages: + first_message = messages[0] + # Extract role + role = None + if hasattr(first_message, "role"): + role = first_message.role + elif isinstance(first_message, dict) and "role" in first_message: + role = first_message["role"] + + if role: _set_span_data_attribute( - span, SPANDATA.MCP_PROMPT_RESULT_MESSAGE_COUNT, message_count + span, SPANDATA.MCP_PROMPT_RESULT_MESSAGE_ROLE, role ) - # Only set role and content for single-message prompts if PII is allowed - if message_count == 1 and should_include_result_data and messages: - first_message = messages[0] - # Extract role - role = None - if hasattr(first_message, "role"): - role = first_message.role - elif isinstance(first_message, dict) and "role" in first_message: - role = first_message["role"] - - if role: - _set_span_data_attribute( - span, SPANDATA.MCP_PROMPT_RESULT_MESSAGE_ROLE, role - ) - - # Extract content text - content_text = None - if hasattr(first_message, "content"): - msg_content = first_message.content - # Content can be a TextContent object or similar - if hasattr(msg_content, "text"): - content_text = msg_content.text - elif isinstance(msg_content, dict) and "text" in msg_content: - content_text = msg_content["text"] - elif isinstance(msg_content, str): - content_text = msg_content - elif isinstance(first_message, dict) and "content" in first_message: - msg_content = first_message["content"] - if isinstance(msg_content, dict) and "text" in msg_content: - content_text = msg_content["text"] - elif isinstance(msg_content, str): - content_text = msg_content - - if content_text: - _set_span_data_attribute( - span, - SPANDATA.MCP_PROMPT_RESULT_MESSAGE_CONTENT, - content_text, - ) - except Exception: - # Silently ignore if we can't extract message info - pass + # Extract content text + content_text = None + if hasattr(first_message, "content"): + msg_content = first_message.content + # Content can be a TextContent object or similar + if hasattr(msg_content, "text"): + content_text = msg_content.text + elif isinstance(msg_content, dict) and "text" in msg_content: + content_text = msg_content["text"] + elif isinstance(msg_content, str): + content_text = msg_content + elif isinstance(first_message, dict) and "content" in first_message: + msg_content = first_message["content"] + if isinstance(msg_content, dict) and "text" in msg_content: + content_text = msg_content["text"] + elif isinstance(msg_content, str): + content_text = msg_content + + if content_text: + _set_span_data_attribute( + span, + SPANDATA.MCP_PROMPT_RESULT_MESSAGE_CONTENT, + content_text, + ) + except Exception: + # Silently ignore if we can't extract message info + pass return result @@ -705,105 +664,92 @@ async def _instrument_v2_prompt_get( # Get request ID, session ID, and transport from context request_id, session_id, mcp_transport = _get_request_context_data(ctx=ctx) - span_streaming = has_span_streaming_enabled(client.options) - # Start span and execute - with _active_http_scopes(ctx=ctx): - span_mgr: "Union[Span, StreamedSpan]" - if span_streaming: - span_mgr = sentry_sdk.traces.start_span( - name=f"prompts/get {handler_name}", - attributes={ - "sentry.op": OP.MCP_SERVER, - "sentry.origin": MCPIntegration.origin, - }, - ) - else: - span_mgr = get_start_span_function()( - op=OP.MCP_SERVER, - name=f"prompts/get {handler_name}", - origin=MCPIntegration.origin, - ) - - with span_mgr as span: - # Set input span data - _set_span_input_data( - span, - handler_name, - SPANDATA.MCP_PROMPT_NAME, - "prompts/get", - arguments, - request_id, - session_id, - mcp_transport, - ) + with _active_http_scopes(ctx=ctx), sentry_sdk.traces.start_span( + name=f"prompts/get {handler_name}", + attributes={ + "sentry.op": OP.MCP_SERVER, + "sentry.origin": MCPIntegration.origin, + }, + ) as span: + # Set input span data + _set_span_input_data( + span, + handler_name, + SPANDATA.MCP_PROMPT_NAME, + "prompts/get", + arguments, + request_id, + session_id, + mcp_transport, + ) - try: - result = await call_next(ctx) - except Exception as e: - with capture_internal_exceptions(): - _capture_exception(e) - raise - - if not isinstance(result, dict): - return result - - # Get integration to check PII settings - integration = client.get_integration(MCPIntegration) - if integration is None: - return result - - # Check if we should include sensitive data - should_include_result_data = False - if has_data_collection_enabled(client.options): - if client.options["data_collection"]["gen_ai"]["inputs"]: - should_include_result_data = True - elif should_send_default_pii() and integration.include_prompts: + try: + result = await call_next(ctx) + except Exception as e: + with capture_internal_exceptions(): + _capture_exception(e) + raise + + if not isinstance(result, dict): + return result + + # Get integration to check PII settings + integration = client.get_integration(MCPIntegration) + if integration is None: + return result + + # Check if we should include sensitive data + should_include_result_data = False + if has_data_collection_enabled(client.options): + if client.options["data_collection"]["gen_ai"]["inputs"]: should_include_result_data = True + elif should_send_default_pii() and integration.include_prompts: + should_include_result_data = True - # For prompts, count messages and set role/content only for single-message prompts - try: - messages: "Optional[list[dict[str, Any]]]" = None - message_count = 0 + # For prompts, count messages and set role/content only for single-message prompts + try: + messages: "Optional[list[dict[str, Any]]]" = None + message_count = 0 - if result.get("messages"): - messages = result["messages"] - message_count = len(messages) + if result.get("messages"): + messages = result["messages"] + message_count = len(messages) - # Always set message count if we found messages - if message_count > 0: + # Always set message count if we found messages + if message_count > 0: + _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_result_data and messages: + first_message = messages[0] + # Extract role + role = None + if "role" in first_message: + role = first_message["role"] + + if role: _set_span_data_attribute( - span, SPANDATA.MCP_PROMPT_RESULT_MESSAGE_COUNT, message_count + span, SPANDATA.MCP_PROMPT_RESULT_MESSAGE_ROLE, role ) - # Only set role and content for single-message prompts if PII is allowed - if message_count == 1 and should_include_result_data and messages: - first_message = messages[0] - # Extract role - role = None - if "role" in first_message: - role = first_message["role"] - - if role: - _set_span_data_attribute( - span, SPANDATA.MCP_PROMPT_RESULT_MESSAGE_ROLE, role - ) - - content_text = None - if "content" in first_message: - msg_content = first_message["content"] - if "text" in msg_content: - content_text = msg_content["text"] - - if content_text: - _set_span_data_attribute( - span, - SPANDATA.MCP_PROMPT_RESULT_MESSAGE_CONTENT, - content_text, - ) - except Exception: - # Silently ignore if we can't extract message info - pass + content_text = None + if "content" in first_message: + msg_content = first_message["content"] + if "text" in msg_content: + content_text = msg_content["text"] + + if content_text: + _set_span_data_attribute( + span, + SPANDATA.MCP_PROMPT_RESULT_MESSAGE_CONTENT, + content_text, + ) + except Exception: + # Silently ignore if we can't extract message info + pass return result @@ -844,65 +790,52 @@ async def _resource_handler_wrapper( # Get request ID, session ID, and transport from context request_id, session_id, mcp_transport = _get_request_context_data(ctx=ctx) - span_streaming = has_span_streaming_enabled(sentry_sdk.get_client().options) - # Start span and execute - with _active_http_scopes(ctx=ctx): - span_mgr: "Union[Span, StreamedSpan]" - if span_streaming: - span_mgr = sentry_sdk.traces.start_span( - name=f"resources/read {handler_name}", - attributes={ - "sentry.op": OP.MCP_SERVER, - "sentry.origin": MCPIntegration.origin, - }, - ) - else: - span_mgr = get_start_span_function()( - op=OP.MCP_SERVER, - name=f"resources/read {handler_name}", - origin=MCPIntegration.origin, - ) - - with span_mgr as span: - # Set input span data - _set_span_input_data( - span, - handler_name, - SPANDATA.MCP_RESOURCE_URI, - "resources/read", - arguments, - request_id, - session_id, - mcp_transport, - ) + with _active_http_scopes(ctx=ctx), sentry_sdk.traces.start_span( + name=f"resources/read {handler_name}", + attributes={ + "sentry.op": OP.MCP_SERVER, + "sentry.origin": MCPIntegration.origin, + }, + ) as span: + # Set input span data + _set_span_input_data( + span, + handler_name, + SPANDATA.MCP_RESOURCE_URI, + "resources/read", + arguments, + request_id, + session_id, + mcp_transport, + ) - if original_args: - uri = original_args[0] - else: - uri = original_kwargs.get("uri") - - protocol = None - if uri is not None and hasattr(uri, "scheme"): - protocol = uri.scheme - elif handler_name and "://" in handler_name: - protocol = handler_name.split("://")[0] - if protocol: - _set_span_data_attribute(span, SPANDATA.MCP_RESOURCE_PROTOCOL, protocol) - - try: - # Execute the async handler - if self is not None: - original_args = (self, *original_args) - - result = func(*original_args, **original_kwargs) - if force_await or inspect.isawaitable(result): - result = await result - - except Exception as e: - with capture_internal_exceptions(): - _capture_exception(e) - raise + if original_args: + uri = original_args[0] + else: + uri = original_kwargs.get("uri") + + protocol = None + if uri is not None and hasattr(uri, "scheme"): + protocol = uri.scheme + elif handler_name and "://" in handler_name: + protocol = handler_name.split("://")[0] + if protocol: + _set_span_data_attribute(span, SPANDATA.MCP_RESOURCE_PROTOCOL, protocol) + + try: + # Execute the async handler + if self is not None: + original_args = (self, *original_args) + + result = func(*original_args, **original_kwargs) + if force_await or inspect.isawaitable(result): + result = await result + + except Exception as e: + with capture_internal_exceptions(): + _capture_exception(e) + raise return result @@ -923,52 +856,39 @@ async def _instrument_v2_resource_read( # Get request ID, session ID, and transport from context request_id, session_id, mcp_transport = _get_request_context_data(ctx=ctx) - span_streaming = has_span_streaming_enabled(sentry_sdk.get_client().options) - # Start span and execute - with _active_http_scopes(ctx=ctx): - span_mgr: "Union[Span, StreamedSpan]" - if span_streaming: - span_mgr = sentry_sdk.traces.start_span( - name=f"resources/read {handler_name}", - attributes={ - "sentry.op": OP.MCP_SERVER, - "sentry.origin": MCPIntegration.origin, - }, - ) - else: - span_mgr = get_start_span_function()( - op=OP.MCP_SERVER, - name=f"resources/read {handler_name}", - origin=MCPIntegration.origin, - ) - - with span_mgr as span: - # Set input span data - _set_span_input_data( - span, - handler_name, - SPANDATA.MCP_RESOURCE_URI, - "resources/read", - {}, - request_id, - session_id, - mcp_transport, - ) + with _active_http_scopes(ctx=ctx), sentry_sdk.traces.start_span( + name=f"resources/read {handler_name}", + attributes={ + "sentry.op": OP.MCP_SERVER, + "sentry.origin": MCPIntegration.origin, + }, + ) as span: + # Set input span data + _set_span_input_data( + span, + handler_name, + SPANDATA.MCP_RESOURCE_URI, + "resources/read", + {}, + request_id, + session_id, + mcp_transport, + ) - protocol = None - if handler_name and "://" in handler_name: - protocol = handler_name.split("://")[0] - if protocol: - _set_span_data_attribute(span, SPANDATA.MCP_RESOURCE_PROTOCOL, protocol) + protocol = None + if handler_name and "://" in handler_name: + protocol = handler_name.split("://")[0] + if protocol: + _set_span_data_attribute(span, SPANDATA.MCP_RESOURCE_PROTOCOL, protocol) - try: - result = await call_next(ctx) + try: + result = await call_next(ctx) - except Exception as e: - with capture_internal_exceptions(): - _capture_exception(e) - raise + except Exception as e: + with capture_internal_exceptions(): + _capture_exception(e) + raise return result diff --git a/tests/integrations/fastmcp/test_fastmcp.py b/tests/integrations/fastmcp/test_fastmcp.py index 3f71be7440..6d59f2353c 100644 --- a/tests/integrations/fastmcp/test_fastmcp.py +++ b/tests/integrations/fastmcp/test_fastmcp.py @@ -45,7 +45,6 @@ async def __call__(self, *args, **kwargs): from mcp.server.sse import SseServerTransport from mcp.server.streamable_http_manager import StreamableHTTPSessionManager -from sentry_sdk import start_transaction from sentry_sdk.consts import OP, SPANDATA from sentry_sdk.integrations.mcp import MCPIntegration @@ -284,23 +283,20 @@ def reset_request_ctx(): "send_default_pii, include_prompts", [(True, True), (True, False), (False, True), (False, False)], ) -@pytest.mark.parametrize("span_streaming", [True, False]) async def test_fastmcp_tool_sync( sentry_init, - capture_events, capture_items, FastMCP, send_default_pii, include_prompts, stdio, - span_streaming, ): """Test that FastMCP 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, - trace_lifecycle="stream" if span_streaming else "static", + trace_lifecycle="stream", ) mcp = FastMCP("Test Server") @@ -310,73 +306,38 @@ def add_numbers(a: int, b: int) -> dict: """Add two numbers together""" return {"result": a + b, "operation": "addition"} - if span_streaming: - items = capture_items("span") - - with sentry_sdk.traces.start_span(name="custom parent"): - # Call through MCP protocol to trigger instrumentation - await stdio( - mcp._mcp_server, - method="tools/call", - params={ - "name": "add_numbers", - "arguments": {"a": 10, "b": 5}, - }, - request_id="req-123", - ) - - sentry_sdk.flush() - spans = [item.payload for item in items] - assert len(spans) == 2 - - # Verify span structure - span = spans[0] - assert span["attributes"]["sentry.op"] == OP.MCP_SERVER - assert span["attributes"]["sentry.origin"] == "auto.ai.mcp" - assert span["name"] == "tools/call add_numbers" - assert span["attributes"][SPANDATA.MCP_TOOL_NAME] == "add_numbers" - assert span["attributes"][SPANDATA.MCP_METHOD_NAME] == "tools/call" - assert span["attributes"][SPANDATA.MCP_TRANSPORT] == "stdio" - assert span["attributes"][SPANDATA.MCP_REQUEST_ID] == "req-123" + items = capture_items("span") + + # Call through MCP protocol to trigger instrumentation + await stdio( + mcp._mcp_server, + method="tools/call", + params={ + "name": "add_numbers", + "arguments": {"a": 10, "b": 5}, + }, + request_id="req-123", + ) - # Check PII-sensitive data - if send_default_pii and include_prompts: - assert SPANDATA.MCP_TOOL_RESULT_CONTENT in span["attributes"] - else: - assert SPANDATA.MCP_TOOL_RESULT_CONTENT not in span["attributes"] + sentry_sdk.flush() + spans = [item.payload for item in items] + assert len(spans) == 1 + + # Verify span structure + span = spans[0] + assert span["attributes"]["sentry.op"] == OP.MCP_SERVER + assert span["attributes"]["sentry.origin"] == "auto.ai.mcp" + assert span["name"] == "tools/call add_numbers" + assert span["attributes"][SPANDATA.MCP_TOOL_NAME] == "add_numbers" + assert span["attributes"][SPANDATA.MCP_METHOD_NAME] == "tools/call" + assert span["attributes"][SPANDATA.MCP_TRANSPORT] == "stdio" + assert span["attributes"][SPANDATA.MCP_REQUEST_ID] == "req-123" + + # Check PII-sensitive data + if send_default_pii and include_prompts: + assert SPANDATA.MCP_TOOL_RESULT_CONTENT in span["attributes"] else: - events = capture_events() - with start_transaction(name="fastmcp tx"): - # Call through MCP protocol to trigger instrumentation - await stdio( - mcp._mcp_server, - method="tools/call", - params={ - "name": "add_numbers", - "arguments": {"a": 10, "b": 5}, - }, - request_id="req-123", - ) - - (tx,) = events - assert tx["type"] == "transaction" - assert len(tx["spans"]) == 1 - - # Verify span structure - span = tx["spans"][0] - assert span["op"] == OP.MCP_SERVER - assert span["origin"] == "auto.ai.mcp" - assert span["description"] == "tools/call add_numbers" - assert span["data"][SPANDATA.MCP_TOOL_NAME] == "add_numbers" - 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" - - # Check PII-sensitive data - if send_default_pii and include_prompts: - assert SPANDATA.MCP_TOOL_RESULT_CONTENT in span["data"] - else: - assert SPANDATA.MCP_TOOL_RESULT_CONTENT not in span["data"] + assert SPANDATA.MCP_TOOL_RESULT_CONTENT not in span["attributes"] @pytest.mark.parametrize("FastMCP", fastmcp_implementations, ids=fastmcp_ids) @@ -385,24 +346,21 @@ def add_numbers(a: int, b: int) -> dict: "send_default_pii, include_prompts", [(True, True), (True, False), (False, True), (False, False)], ) -@pytest.mark.parametrize("span_streaming", [True, False]) async def test_fastmcp_tool_async( sentry_init, - capture_events, capture_items, FastMCP, send_default_pii, include_prompts, json_rpc, select_transactions_with_mcp_spans, - span_streaming, ): """Test that FastMCP async tool handlers create proper spans""" sentry_init( integrations=[MCPIntegration(include_prompts=include_prompts)], traces_sample_rate=1.0, send_default_pii=send_default_pii, - trace_lifecycle="stream" if span_streaming else "static", + trace_lifecycle="stream", ) mcp = FastMCP("Test Server") @@ -424,100 +382,56 @@ async def multiply_numbers(x: int, y: int) -> dict: """Multiply two numbers together""" return {"result": x * y, "operation": "multiplication"} - if span_streaming: - items = capture_items("span") - - session_id, result = json_rpc( - app, - method="tools/call", - params={ - "name": "multiply_numbers", - "arguments": {"x": 7, "y": 6}, - }, - request_id="req-456", - ) + items = capture_items("span") - assert json.loads(result.json()["result"]["content"][0]["text"]) == { - "result": 42, - "operation": "multiplication", - } - - sentry_sdk.flush() - spans = [item.payload for item in items] - spans = [ - span - for span in spans - if span["attributes"].get("mcp.method.name") == "tools/call" - ] - assert len(spans) == 1 - span = spans[0] - - assert span["attributes"]["sentry.op"] == OP.MCP_SERVER - assert span["attributes"]["sentry.origin"] == "auto.ai.mcp" - assert span["name"] == "tools/call multiply_numbers" - assert span["attributes"][SPANDATA.MCP_TOOL_NAME] == "multiply_numbers" - assert span["attributes"][SPANDATA.MCP_METHOD_NAME] == "tools/call" - assert span["attributes"][SPANDATA.MCP_TRANSPORT] == "http" - assert span["attributes"][SPANDATA.MCP_REQUEST_ID] == "req-456" - assert span["attributes"][SPANDATA.MCP_SESSION_ID] == session_id + session_id, result = json_rpc( + app, + method="tools/call", + params={ + "name": "multiply_numbers", + "arguments": {"x": 7, "y": 6}, + }, + request_id="req-456", + ) - # Check PII-sensitive data - if send_default_pii and include_prompts: - assert SPANDATA.MCP_TOOL_RESULT_CONTENT in span["attributes"] - else: - assert SPANDATA.MCP_TOOL_RESULT_CONTENT not in span["attributes"] + assert json.loads(result.json()["result"]["content"][0]["text"]) == { + "result": 42, + "operation": "multiplication", + } + + sentry_sdk.flush() + spans = [item.payload for item in items] + spans = [ + span + for span in spans + if span["attributes"].get("mcp.method.name") == "tools/call" + ] + assert len(spans) == 1 + span = spans[0] + + assert span["attributes"]["sentry.op"] == OP.MCP_SERVER + assert span["attributes"]["sentry.origin"] == "auto.ai.mcp" + assert span["name"] == "tools/call multiply_numbers" + assert span["attributes"][SPANDATA.MCP_TOOL_NAME] == "multiply_numbers" + assert span["attributes"][SPANDATA.MCP_METHOD_NAME] == "tools/call" + assert span["attributes"][SPANDATA.MCP_TRANSPORT] == "http" + assert span["attributes"][SPANDATA.MCP_REQUEST_ID] == "req-456" + assert span["attributes"][SPANDATA.MCP_SESSION_ID] == session_id + + # Check PII-sensitive data + if send_default_pii and include_prompts: + assert SPANDATA.MCP_TOOL_RESULT_CONTENT in span["attributes"] else: - events = capture_events() - - session_id, result = json_rpc( - app, - method="tools/call", - params={ - "name": "multiply_numbers", - "arguments": {"x": 7, "y": 6}, - }, - request_id="req-456", - ) - - assert json.loads(result.json()["result"]["content"][0]["text"]) == { - "result": 42, - "operation": "multiplication", - } - - transactions = select_transactions_with_mcp_spans( - events, method_name="tools/call" - ) - assert len(transactions) == 1 - tx = transactions[0] - assert len(tx["spans"]) == 1 - span = tx["spans"][0] - - assert span["op"] == OP.MCP_SERVER - assert span["origin"] == "auto.ai.mcp" - assert span["description"] == "tools/call multiply_numbers" - assert span["data"][SPANDATA.MCP_TOOL_NAME] == "multiply_numbers" - 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 - - # Check PII-sensitive data - if send_default_pii and include_prompts: - assert SPANDATA.MCP_TOOL_RESULT_CONTENT in span["data"] - else: - assert SPANDATA.MCP_TOOL_RESULT_CONTENT not in span["data"] + assert SPANDATA.MCP_TOOL_RESULT_CONTENT not in span["attributes"] @pytest.mark.asyncio @pytest.mark.parametrize("FastMCP", fastmcp_implementations, ids=fastmcp_ids) -@pytest.mark.parametrize("span_streaming", [True, False]) async def test_fastmcp_tool_with_error( sentry_init, - capture_events, capture_items, FastMCP, stdio, - span_streaming, ): """Test that FastMCP tool handler errors are captured properly""" # TODO: This test doesn't capture errors via the MCP integration, but rather @@ -525,7 +439,7 @@ async def test_fastmcp_tool_with_error( sentry_init( integrations=[MCPIntegration(), LoggingIntegration(event_level=logging.ERROR)], traces_sample_rate=1.0, - trace_lifecycle="stream" if span_streaming else "static", + trace_lifecycle="stream", ) mcp = FastMCP("Test Server") @@ -535,232 +449,47 @@ def failing_tool(value: int) -> int: """A tool that always fails""" raise ValueError("Tool execution failed") - if span_streaming: - items = capture_items("event", "span") - with sentry_sdk.traces.start_span(name="custom parent"): - await stdio( - mcp._mcp_server, - method="tools/call", - params={ - "name": "failing_tool", - "arguments": {"value": 42}, - }, - request_id="req-error", - ) - - sentry_sdk.flush() - # Check span was created - spans = [item.payload for item in items if item.type == "span"] - tool_spans = [ - s for s in spans if s["attributes"].get("sentry.op") == OP.MCP_SERVER - ] - - assert len(tool_spans) == 1 - - # Check error event was captured - events = [item.payload for item in items if item.type == "event"] - error_events = [e for e in events if e.get("level") == "error"] - assert len(error_events) >= 1 - error_event = error_events[0] - assert error_event["exception"]["values"][0]["type"] == "ValueError" - assert error_event["exception"]["values"][0]["value"] == "Tool execution failed" - else: - events = capture_events() - with start_transaction(name="fastmcp tx"): - await stdio( - mcp._mcp_server, - method="tools/call", - params={ - "name": "failing_tool", - "arguments": {"value": 42}, - }, - request_id="req-error", - ) - - # Should have transaction and error events - assert len(events) >= 1 - - # Check span was created - tx = [e for e in events if e.get("type") == "transaction"][0] - tool_spans = [s for s in tx["spans"] if s["op"] == OP.MCP_SERVER] - - assert len(tool_spans) == 1 - - # Check error event was captured - error_events = [e for e in events if e.get("level") == "error"] - assert len(error_events) >= 1 - error_event = error_events[0] - assert error_event["exception"]["values"][0]["type"] == "ValueError" - assert error_event["exception"]["values"][0]["value"] == "Tool execution failed" - - -@pytest.mark.asyncio -@pytest.mark.parametrize("FastMCP", fastmcp_implementations, ids=fastmcp_ids) -@pytest.mark.parametrize("span_streaming", [True, False]) -async def test_fastmcp_multiple_tools( - sentry_init, - capture_events, - capture_items, - FastMCP, - stdio, - span_streaming, -): - """Test that multiple FastMCP tool calls create multiple spans""" - sentry_init( - integrations=[MCPIntegration()], - traces_sample_rate=1.0, - trace_lifecycle="stream" if span_streaming else "static", + items = capture_items("event", "span") + await stdio( + mcp._mcp_server, + method="tools/call", + params={ + "name": "failing_tool", + "arguments": {"value": 42}, + }, + request_id="req-error", ) - mcp = FastMCP("Test Server") - - @mcp.tool() - def tool_one(x: int) -> int: - """First tool""" - return x * 2 - - @mcp.tool() - def tool_two(y: int) -> int: - """Second tool""" - return y + 10 + sentry_sdk.flush() + # Check span was created + spans = [item.payload for item in items if item.type == "span"] + tool_spans = [s for s in spans if s["attributes"].get("sentry.op") == OP.MCP_SERVER] - @mcp.tool() - def tool_three(z: int) -> int: - """Third tool""" - return z - 5 + assert len(tool_spans) == 1 - if span_streaming: - items = capture_items("span") - with sentry_sdk.traces.start_span(name="custom parent"): - result1 = await stdio( - mcp._mcp_server, - method="tools/call", - params={ - "name": "tool_one", - "arguments": {"x": 5}, - }, - request_id="req-multi", - ) - - result2 = await stdio( - mcp._mcp_server, - method="tools/call", - params={ - "name": "tool_two", - "arguments": { - "y": int( - result1.message.result["content"][0]["text"] - if MCP_PACKAGE_VERSION is not None - and MCP_PACKAGE_VERSION >= (2,) - else result1.message.root.result["content"][0]["text"] - ) - }, - }, - request_id="req-multi", - ) - - await stdio( - mcp._mcp_server, - method="tools/call", - params={ - "name": "tool_three", - "arguments": { - "z": int( - result2.message.result["content"][0]["text"] - if MCP_PACKAGE_VERSION is not None - and MCP_PACKAGE_VERSION >= (2,) - else result2.message.root.result["content"][0]["text"] - ) - }, - }, - request_id="req-multi", - ) - - sentry_sdk.flush() - # Verify three spans were created - spans = [item.payload for item in items] - tool_spans = [ - s for s in spans if s["attributes"].get("sentry.op") == OP.MCP_SERVER - ] - assert len(tool_spans) == 3 - assert tool_spans[0]["attributes"][SPANDATA.MCP_TOOL_NAME] == "tool_one" - assert tool_spans[1]["attributes"][SPANDATA.MCP_TOOL_NAME] == "tool_two" - assert tool_spans[2]["attributes"][SPANDATA.MCP_TOOL_NAME] == "tool_three" - else: - events = capture_events() - with start_transaction(name="fastmcp tx"): - result1 = await stdio( - mcp._mcp_server, - method="tools/call", - params={ - "name": "tool_one", - "arguments": {"x": 5}, - }, - request_id="req-multi", - ) - - result2 = await stdio( - mcp._mcp_server, - method="tools/call", - params={ - "name": "tool_two", - "arguments": { - "y": int( - result1.message.result["content"][0]["text"] - if MCP_PACKAGE_VERSION is not None - and MCP_PACKAGE_VERSION >= (2,) - else result1.message.root.result["content"][0]["text"] - ) - }, - }, - request_id="req-multi", - ) - - await stdio( - mcp._mcp_server, - method="tools/call", - params={ - "name": "tool_three", - "arguments": { - "z": int( - result2.message.result["content"][0]["text"] - if MCP_PACKAGE_VERSION is not None - and MCP_PACKAGE_VERSION >= (2,) - else result2.message.root.result["content"][0]["text"] - ) - }, - }, - request_id="req-multi", - ) - - (tx,) = events - assert tx["type"] == "transaction" - - # Verify three spans were created - tool_spans = [s for s in tx["spans"] if s["op"] == OP.MCP_SERVER] - assert len(tool_spans) == 3 - assert tool_spans[0]["data"][SPANDATA.MCP_TOOL_NAME] == "tool_one" - assert tool_spans[1]["data"][SPANDATA.MCP_TOOL_NAME] == "tool_two" - assert tool_spans[2]["data"][SPANDATA.MCP_TOOL_NAME] == "tool_three" + # Check error event was captured + events = [item.payload for item in items if item.type == "event"] + error_events = [e for e in events if e.get("level") == "error"] + assert len(error_events) >= 1 + error_event = error_events[0] + assert error_event["exception"]["values"][0]["type"] == "ValueError" + assert error_event["exception"]["values"][0]["value"] == "Tool execution failed" @pytest.mark.asyncio @pytest.mark.parametrize("FastMCP", fastmcp_implementations, ids=fastmcp_ids) -@pytest.mark.parametrize("span_streaming", [True, False]) async def test_fastmcp_tool_with_complex_return( sentry_init, - capture_events, capture_items, FastMCP, stdio, - span_streaming, ): """Test FastMCP tool with complex nested return value""" sentry_init( integrations=[MCPIntegration(include_prompts=True)], traces_sample_rate=1.0, send_default_pii=True, - trace_lifecycle="stream" if span_streaming else "static", + trace_lifecycle="stream", ) mcp = FastMCP("Test Server") @@ -775,53 +504,26 @@ def get_user_data(user_id: int) -> dict: "tags": ["admin", "verified"], } - if span_streaming: - items = capture_items("span") - with sentry_sdk.traces.start_span(name="custom parent"): - await stdio( - mcp._mcp_server, - method="tools/call", - params={ - "name": "get_user_data", - "arguments": {"user_id": 123}, - }, - request_id="req-complex", - ) - - sentry_sdk.flush() - # Verify span was created with complex data - spans = [item.payload for item in items] - tool_spans = [ - s for s in spans if s["attributes"].get("sentry.op") == OP.MCP_SERVER - ] - assert len(tool_spans) == 1 - assert tool_spans[0]["attributes"]["sentry.op"] == OP.MCP_SERVER - assert tool_spans[0]["attributes"][SPANDATA.MCP_TOOL_NAME] == "get_user_data" - # Complex return value should be captured since include_prompts=True and send_default_pii=True - assert SPANDATA.MCP_TOOL_RESULT_CONTENT in tool_spans[0]["attributes"] - else: - events = capture_events() - with start_transaction(name="fastmcp tx"): - await stdio( - mcp._mcp_server, - method="tools/call", - params={ - "name": "get_user_data", - "arguments": {"user_id": 123}, - }, - request_id="req-complex", - ) - - (tx,) = events - assert tx["type"] == "transaction" + items = capture_items("span") + await stdio( + mcp._mcp_server, + method="tools/call", + params={ + "name": "get_user_data", + "arguments": {"user_id": 123}, + }, + request_id="req-complex", + ) - # Verify span was created with complex data - tool_spans = [s for s in tx["spans"] if s["op"] == OP.MCP_SERVER] - assert len(tool_spans) == 1 - assert tool_spans[0]["op"] == OP.MCP_SERVER - assert tool_spans[0]["data"][SPANDATA.MCP_TOOL_NAME] == "get_user_data" - # Complex return value should be captured since include_prompts=True and send_default_pii=True - assert SPANDATA.MCP_TOOL_RESULT_CONTENT in tool_spans[0]["data"] + sentry_sdk.flush() + # Verify span was created with complex data + spans = [item.payload for item in items] + tool_spans = [s for s in spans if s["attributes"].get("sentry.op") == OP.MCP_SERVER] + assert len(tool_spans) == 1 + assert tool_spans[0]["attributes"]["sentry.op"] == OP.MCP_SERVER + assert tool_spans[0]["attributes"][SPANDATA.MCP_TOOL_NAME] == "get_user_data" + # Complex return value should be captured since include_prompts=True and send_default_pii=True + assert SPANDATA.MCP_TOOL_RESULT_CONTENT in tool_spans[0]["attributes"] # ============================================================================= @@ -835,23 +537,20 @@ def get_user_data(user_id: int) -> dict: "send_default_pii, include_prompts", [(True, True), (False, False)], ) -@pytest.mark.parametrize("span_streaming", [True, False]) async def test_fastmcp_prompt_sync( sentry_init, - capture_events, capture_items, FastMCP, send_default_pii, include_prompts, stdio, - span_streaming, ): """Test that FastMCP 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, - trace_lifecycle="stream" if span_streaming else "static", + trace_lifecycle="stream", ) mcp = FastMCP("Test Server") @@ -875,67 +574,35 @@ def code_help_prompt(language: str): return [message] - if span_streaming: - items = capture_items("span") - with sentry_sdk.traces.start_span(name="custom parent"): - await stdio( - mcp._mcp_server, - method="prompts/get", - params={ - "name": "code_help_prompt", - "arguments": {"language": "python"}, - }, - request_id="req-prompt", - ) - - sentry_sdk.flush() - # Verify prompt span was created - spans = [item.payload for item in items] - prompt_spans = [ - s for s in spans if s["attributes"].get("sentry.op") == OP.MCP_SERVER - ] - assert len(prompt_spans) == 1 - span = prompt_spans[0] - assert span["attributes"]["sentry.origin"] == "auto.ai.mcp" - assert span["name"] == "prompts/get code_help_prompt" - assert span["attributes"][SPANDATA.MCP_PROMPT_NAME] == "code_help_prompt" - - # Check PII-sensitive data - if send_default_pii and include_prompts: - assert SPANDATA.MCP_PROMPT_RESULT_MESSAGE_CONTENT in span["attributes"] - else: - assert ( - SPANDATA.MCP_PROMPT_RESULT_MESSAGE_CONTENT not in span["attributes"] - ) + items = capture_items("span") + with sentry_sdk.traces.start_span(name="custom parent"): + await stdio( + mcp._mcp_server, + method="prompts/get", + params={ + "name": "code_help_prompt", + "arguments": {"language": "python"}, + }, + request_id="req-prompt", + ) + + sentry_sdk.flush() + # Verify prompt span was created + spans = [item.payload for item in items] + prompt_spans = [ + s for s in spans if s["attributes"].get("sentry.op") == OP.MCP_SERVER + ] + assert len(prompt_spans) == 1 + span = prompt_spans[0] + assert span["attributes"]["sentry.origin"] == "auto.ai.mcp" + assert span["name"] == "prompts/get code_help_prompt" + assert span["attributes"][SPANDATA.MCP_PROMPT_NAME] == "code_help_prompt" + + # Check PII-sensitive data + if send_default_pii and include_prompts: + assert SPANDATA.MCP_PROMPT_RESULT_MESSAGE_CONTENT in span["attributes"] else: - events = capture_events() - with start_transaction(name="fastmcp tx"): - await stdio( - mcp._mcp_server, - method="prompts/get", - params={ - "name": "code_help_prompt", - "arguments": {"language": "python"}, - }, - request_id="req-prompt", - ) - - (tx,) = events - assert tx["type"] == "transaction" - - # Verify prompt span was created - prompt_spans = [s for s in tx["spans"] if s["op"] == OP.MCP_SERVER] - assert len(prompt_spans) == 1 - span = prompt_spans[0] - assert span["origin"] == "auto.ai.mcp" - assert span["description"] == "prompts/get code_help_prompt" - assert span["data"][SPANDATA.MCP_PROMPT_NAME] == "code_help_prompt" - - # Check PII-sensitive data - if send_default_pii and include_prompts: - assert SPANDATA.MCP_PROMPT_RESULT_MESSAGE_CONTENT in span["data"] - else: - assert SPANDATA.MCP_PROMPT_RESULT_MESSAGE_CONTENT not in span["data"] + assert SPANDATA.MCP_PROMPT_RESULT_MESSAGE_CONTENT not in span["attributes"] # ============================================================================= @@ -949,20 +616,17 @@ def code_help_prompt(language: str): ) @pytest.mark.asyncio @pytest.mark.parametrize("FastMCP", fastmcp_implementations, ids=fastmcp_ids) -@pytest.mark.parametrize("span_streaming", [True, False]) async def test_fastmcp_resource_sync( sentry_init, - capture_events, capture_items, FastMCP, stdio, - span_streaming, ): """Test that FastMCP synchronous resource handlers create proper spans""" sentry_init( integrations=[MCPIntegration()], traces_sample_rate=1.0, - trace_lifecycle="stream" if span_streaming else "static", + trace_lifecycle="stream", ) mcp = FastMCP("Test Server") @@ -975,67 +639,33 @@ def read_file(path: str): """Read a file resource""" return "file contents" - if span_streaming: - items = capture_items("span") - with sentry_sdk.traces.start_span(name="custom parent"): - try: - await stdio( - mcp._mcp_server, - method="resources/read", - params={ - "uri": "file:///test.txt", - }, - request_id="req-resource", - ) - except ValueError as e: - # Older FastMCP versions may not support this URI pattern - if "Unknown resource" in str(e): - pytest.skip( - f"Resource URI not supported in this FastMCP version: {e}" - ) - raise - - sentry_sdk.flush() - # Verify resource span was created - spans = [item.payload for item in items] - resource_spans = [ - s for s in spans if s["attributes"].get("sentry.op") == OP.MCP_SERVER - ] - assert len(resource_spans) == 1 - span = resource_spans[0] - assert span["attributes"]["sentry.origin"] == "auto.ai.mcp" - assert span["name"] == "resources/read file:///test.txt" - assert span["attributes"][SPANDATA.MCP_RESOURCE_PROTOCOL] == "file" - else: - events = capture_events() - with start_transaction(name="fastmcp tx"): - try: - await stdio( - mcp._mcp_server, - method="resources/read", - params={ - "uri": "file:///test.txt", - }, - request_id="req-resource", - ) - except ValueError as e: - # Older FastMCP versions may not support this URI pattern - if "Unknown resource" in str(e): - pytest.skip( - f"Resource URI not supported in this FastMCP version: {e}" - ) - raise - - (tx,) = events - assert tx["type"] == "transaction" - - # Verify resource span was created - resource_spans = [s for s in tx["spans"] if s["op"] == OP.MCP_SERVER] - assert len(resource_spans) == 1 - span = resource_spans[0] - assert span["origin"] == "auto.ai.mcp" - assert span["description"] == "resources/read file:///test.txt" - assert span["data"][SPANDATA.MCP_RESOURCE_PROTOCOL] == "file" + items = capture_items("span") + try: + await stdio( + mcp._mcp_server, + method="resources/read", + params={ + "uri": "file:///test.txt", + }, + request_id="req-resource", + ) + except ValueError as e: + # Older FastMCP versions may not support this URI pattern + if "Unknown resource" in str(e): + pytest.skip(f"Resource URI not supported in this FastMCP version: {e}") + raise + + sentry_sdk.flush() + # Verify resource span was created + spans = [item.payload for item in items] + resource_spans = [ + s for s in spans if s["attributes"].get("sentry.op") == OP.MCP_SERVER + ] + assert len(resource_spans) == 1 + span = resource_spans[0] + assert span["attributes"]["sentry.origin"] == "auto.ai.mcp" + assert span["name"] == "resources/read file:///test.txt" + assert span["attributes"][SPANDATA.MCP_RESOURCE_PROTOCOL] == "file" @pytest.mark.skipif( @@ -1044,21 +674,18 @@ def read_file(path: str): ) @pytest.mark.parametrize("FastMCP", fastmcp_implementations, ids=fastmcp_ids) @pytest.mark.asyncio -@pytest.mark.parametrize("span_streaming", [True, False]) async def test_fastmcp_resource_async( sentry_init, - capture_events, capture_items, FastMCP, json_rpc, select_transactions_with_mcp_spans, - span_streaming, ): """Test that FastMCP async resource handlers create proper spans""" sentry_init( integrations=[MCPIntegration()], traces_sample_rate=1.0, - trace_lifecycle="stream" if span_streaming else "static", + trace_lifecycle="stream", ) mcp = FastMCP("Test Server") @@ -1077,78 +704,42 @@ async def test_fastmcp_resource_async( # Try to register an async resource handler if hasattr(mcp, "resource"): - if span_streaming: - items = capture_items("span") - - @mcp.resource("https://example.com/{resource}") - async def read_url(resource: str): - """Read a URL resource""" - return "resource data" - - _, result = json_rpc( - app, - method="resources/read", - params={ - "uri": "https://example.com/resource", - }, - request_id="req-async-resource", - ) - # Older FastMCP versions may not support this URI pattern - if ( - "error" in result.json() - and "Unknown resource" in result.json()["error"]["message"] - ): - pytest.skip("Resource URI not supported in this FastMCP version.") - return - - assert "resource data" in result.json()["result"]["contents"][0]["text"] - - sentry_sdk.flush() - spans = [item.payload for item in items] - spans = [ - span - for span in spans - if span["attributes"].get("mcp.method.name") == "resources/read" - ] - assert len(spans) == 1 - span = spans[0] - - assert span["attributes"][SPANDATA.MCP_RESOURCE_PROTOCOL] == "https" - else: - events = capture_events() + items = capture_items("span") - @mcp.resource("https://example.com/{resource}") - async def read_url(resource: str): - """Read a URL resource""" - return "resource data" + @mcp.resource("https://example.com/{resource}") + async def read_url(resource: str): + """Read a URL resource""" + return "resource data" - _, result = json_rpc( - app, - method="resources/read", - params={ - "uri": "https://example.com/resource", - }, - request_id="req-async-resource", - ) - # Older FastMCP versions may not support this URI pattern - if ( - "error" in result.json() - and "Unknown resource" in result.json()["error"]["message"] - ): - pytest.skip("Resource URI not supported in this FastMCP version.") - return + _, result = json_rpc( + app, + method="resources/read", + params={ + "uri": "https://example.com/resource", + }, + request_id="req-async-resource", + ) + # Older FastMCP versions may not support this URI pattern + if ( + "error" in result.json() + and "Unknown resource" in result.json()["error"]["message"] + ): + pytest.skip("Resource URI not supported in this FastMCP version.") + return - assert "resource data" in result.json()["result"]["contents"][0]["text"] + assert "resource data" in result.json()["result"]["contents"][0]["text"] - transactions = select_transactions_with_mcp_spans( - events, method_name="resources/read" - ) - assert len(transactions) == 1 - tx = transactions[0] - assert len(tx["spans"]) == 1 - span = tx["spans"][0] + sentry_sdk.flush() + spans = [item.payload for item in items] + spans = [ + span + for span in spans + if span["attributes"].get("mcp.method.name") == "resources/read" + ] + assert len(spans) == 1 + span = spans[0] - assert span["data"][SPANDATA.MCP_RESOURCE_PROTOCOL] == "https" + assert span["attributes"][SPANDATA.MCP_RESOURCE_PROTOCOL] == "https" # ============================================================================= @@ -1158,20 +749,17 @@ async def read_url(resource: str): @pytest.mark.asyncio @pytest.mark.parametrize("FastMCP", fastmcp_implementations, ids=fastmcp_ids) -@pytest.mark.parametrize("span_streaming", [True, False]) async def test_fastmcp_span_origin( sentry_init, - capture_events, capture_items, FastMCP, stdio, - span_streaming, ): """Test that FastMCP span origin is set correctly""" sentry_init( integrations=[MCPIntegration()], traces_sample_rate=1.0, - trace_lifecycle="stream" if span_streaming else "static", + trace_lifecycle="stream", ) mcp = FastMCP("Test Server") @@ -1181,51 +769,25 @@ def test_tool(value: int) -> int: """Test tool for origin checking""" return value * 2 - if span_streaming: - items = capture_items("span") - with sentry_sdk.traces.start_span(name="custom parent"): - await stdio( - mcp._mcp_server, - method="tools/call", - params={ - "name": "test_tool", - "arguments": {"value": 21}, - }, - request_id="req-origin", - ) - - sentry_sdk.flush() - - spans = [item.payload for item in items] - assert spans[-1]["attributes"]["sentry.origin"] == "manual" - - # Verify MCP span has correct origin - mcp_spans = [ - s for s in spans if s["attributes"].get("sentry.op") == OP.MCP_SERVER - ] - assert len(mcp_spans) == 1 - assert mcp_spans[0]["attributes"]["sentry.origin"] == "auto.ai.mcp" - else: - events = capture_events() - with start_transaction(name="fastmcp tx"): - await stdio( - mcp._mcp_server, - method="tools/call", - params={ - "name": "test_tool", - "arguments": {"value": 21}, - }, - request_id="req-origin", - ) + items = capture_items("span") + await stdio( + mcp._mcp_server, + method="tools/call", + params={ + "name": "test_tool", + "arguments": {"value": 21}, + }, + request_id="req-origin", + ) - (tx,) = events + sentry_sdk.flush() - assert tx["contexts"]["trace"]["origin"] == "manual" + spans = [item.payload for item in items] - # Verify MCP span has correct origin - mcp_spans = [s for s in tx["spans"] if s["op"] == OP.MCP_SERVER] - assert len(mcp_spans) == 1 - assert mcp_spans[0]["origin"] == "auto.ai.mcp" + # Verify MCP span has correct origin + mcp_spans = [s for s in spans if s["attributes"].get("sentry.op") == OP.MCP_SERVER] + assert len(mcp_spans) == 1 + assert mcp_spans[0]["attributes"]["sentry.origin"] == "auto.ai.mcp" # ============================================================================= @@ -1235,24 +797,21 @@ def test_tool(value: int) -> int: @pytest.mark.asyncio @pytest.mark.parametrize("FastMCP", fastmcp_implementations, ids=fastmcp_ids) -@pytest.mark.parametrize("span_streaming", [True, False]) @pytest.mark.skipif( StandaloneFastMCP and (FASTMCP_VERSION is not None and FASTMCP_VERSION >= (4,)), reason="SSE tracing not (yet) supported in v4.", ) async def test_fastmcp_sse_transport( sentry_init, - capture_events, capture_items, FastMCP, json_rpc_sse, - span_streaming, ): """Test that FastMCP correctly detects SSE transport""" sentry_init( integrations=[MCPIntegration()], traces_sample_rate=1.0, - trace_lifecycle="stream" if span_streaming else "static", + trace_lifecycle="stream", ) mcp = FastMCP("Test Server") @@ -1291,93 +850,50 @@ def sse_tool(value: str) -> dict: return {"message": f"Received: {value}"} keep_sse_alive = asyncio.Event() + items = capture_items("span") + + app_task, _, result = await json_rpc_sse( + app, + method="tools/call", + params={ + "name": "sse_tool", + "arguments": {"value": "hello"}, + }, + request_id="req-sse", + keep_sse_alive=keep_sse_alive, + ) - if span_streaming: - items = capture_items("span") - - app_task, _, result = await json_rpc_sse( - app, - method="tools/call", - params={ - "name": "sse_tool", - "arguments": {"value": "hello"}, - }, - request_id="req-sse", - keep_sse_alive=keep_sse_alive, - ) - - await sse_connection_closed.wait() - await app_task - - assert json.loads(result["result"]["content"][0]["text"]) == { - "message": "Received: hello" - } - - sentry_sdk.flush() - # Find MCP spans - spans = [item.payload for item in items] - mcp_spans = [ - s for s in spans if s["attributes"].get("sentry.op") == OP.MCP_SERVER - ] - - assert len(mcp_spans) >= 1 - span = mcp_spans[0] - # Check that SSE transport is detected - assert span["attributes"].get(SPANDATA.MCP_TRANSPORT) == "sse" - else: - events = capture_events() - - app_task, _, result = await json_rpc_sse( - app, - method="tools/call", - params={ - "name": "sse_tool", - "arguments": {"value": "hello"}, - }, - request_id="req-sse", - keep_sse_alive=keep_sse_alive, - ) - - await sse_connection_closed.wait() - await app_task - - assert json.loads(result["result"]["content"][0]["text"]) == { - "message": "Received: hello" - } + await sse_connection_closed.wait() + await app_task - transactions = [ - event - for event in events - if event["type"] == "transaction" and event["transaction"] == "/sse" - ] - assert len(transactions) == 1 - tx = transactions[0] + assert json.loads(result["result"]["content"][0]["text"]) == { + "message": "Received: hello" + } - # Find MCP spans - mcp_spans = [s for s in tx["spans"] if s["op"] == OP.MCP_SERVER] + sentry_sdk.flush() + # Find MCP spans + spans = [item.payload for item in items] + mcp_spans = [s for s in spans if s["attributes"].get("sentry.op") == OP.MCP_SERVER] - assert len(mcp_spans) >= 1 - span = mcp_spans[0] - # Check that SSE transport is detected - assert span["data"].get(SPANDATA.MCP_TRANSPORT) == "sse" + assert len(mcp_spans) >= 1 + span = mcp_spans[0] + # Check that SSE transport is detected + assert span["attributes"].get(SPANDATA.MCP_TRANSPORT) == "sse" @pytest.mark.parametrize("FastMCP", fastmcp_implementations, ids=fastmcp_ids) -@pytest.mark.parametrize("span_streaming", [True, False]) def test_fastmcp_http_transport( sentry_init, - capture_events, capture_items, FastMCP, json_rpc, select_transactions_with_mcp_spans, - span_streaming, ): """Test that FastMCP correctly detects HTTP transport""" sentry_init( integrations=[MCPIntegration()], traces_sample_rate=1.0, - trace_lifecycle="stream" if span_streaming else "static", + trace_lifecycle="stream", ) mcp = FastMCP("Test Server") @@ -1399,80 +915,49 @@ def http_tool(data: str) -> dict: """Tool for HTTP transport test""" return {"processed": data.upper()} - if span_streaming: - items = capture_items("span") + items = capture_items("span") - _, result = json_rpc( - app, - method="tools/call", - params={ - "name": "http_tool", - "arguments": {"data": "test"}, - }, - request_id="req-http", - ) - - assert json.loads(result.json()["result"]["content"][0]["text"]) == { - "processed": "TEST" - } - - sentry_sdk.flush() - spans = [item.payload for item in items] - spans = [ - span - for span in spans - if span["attributes"].get("mcp.method.name") == "tools/call" - ] - assert len(spans) == 1 - span = spans[0] - - # Check that HTTP transport is detected - assert span["attributes"].get(SPANDATA.MCP_TRANSPORT) == "http" - else: - events = capture_events() - - _, result = json_rpc( - app, - method="tools/call", - params={ - "name": "http_tool", - "arguments": {"data": "test"}, - }, - request_id="req-http", - ) + _, result = json_rpc( + app, + method="tools/call", + params={ + "name": "http_tool", + "arguments": {"data": "test"}, + }, + request_id="req-http", + ) - assert json.loads(result.json()["result"]["content"][0]["text"]) == { - "processed": "TEST" - } + assert json.loads(result.json()["result"]["content"][0]["text"]) == { + "processed": "TEST" + } - transactions = select_transactions_with_mcp_spans( - events, method_name="tools/call" - ) - assert len(transactions) == 1 - tx = transactions[0] - assert len(tx["spans"]) == 1 - span = tx["spans"][0] + sentry_sdk.flush() + spans = [item.payload for item in items] + spans = [ + span + for span in spans + if span["attributes"].get("mcp.method.name") == "tools/call" + ] + assert len(spans) == 1 + span = spans[0] - # Check that HTTP transport is detected - assert span["data"].get(SPANDATA.MCP_TRANSPORT) == "http" + # Check that HTTP transport is detected + assert span["attributes"].get(SPANDATA.MCP_TRANSPORT) == "http" @pytest.mark.asyncio @pytest.mark.parametrize("FastMCP", fastmcp_implementations, ids=fastmcp_ids) -@pytest.mark.parametrize("span_streaming", [True, False]) async def test_fastmcp_stdio_transport( sentry_init, - capture_events, capture_items, FastMCP, stdio, - span_streaming, ): """Test that FastMCP correctly detects stdio transport""" sentry_init( integrations=[MCPIntegration()], traces_sample_rate=1.0, - trace_lifecycle="stream" if span_streaming else "static", + trace_lifecycle="stream", ) mcp = FastMCP("Test Server") @@ -1482,146 +967,24 @@ def stdio_tool(n: int) -> dict: """Tool for stdio transport test""" return {"squared": n * n} - if span_streaming: - items = capture_items("span") - with sentry_sdk.traces.start_span(name="custom parent"): - await stdio( - mcp._mcp_server, - method="tools/call", - params={ - "name": "stdio_tool", - "arguments": {"n": 7}, - }, - request_id="req-stdio", - ) - - sentry_sdk.flush() - # Find MCP spans - spans = [item.payload for item in items] - mcp_spans = [ - s for s in spans if s["attributes"].get("sentry.op") == OP.MCP_SERVER - ] - - assert len(mcp_spans) >= 1 - span = mcp_spans[0] - # Check that stdio transport is detected - - assert span["attributes"].get(SPANDATA.MCP_TRANSPORT) == "stdio" - else: - events = capture_events() - with start_transaction(name="fastmcp tx"): - await stdio( - mcp._mcp_server, - method="tools/call", - params={ - "name": "stdio_tool", - "arguments": {"n": 7}, - }, - request_id="req-stdio", - ) - - (tx,) = events - - # Find MCP spans - mcp_spans = [s for s in tx["spans"] if s["op"] == OP.MCP_SERVER] - - assert len(mcp_spans) >= 1 - span = mcp_spans[0] - # Check that stdio transport is detected - - assert span["data"].get(SPANDATA.MCP_TRANSPORT) == "stdio" - - -@pytest.mark.asyncio -@pytest.mark.parametrize("FastMCP", fastmcp_implementations, ids=fastmcp_ids) -@pytest.mark.parametrize("span_streaming", [True, False]) -async def test_fastmcp_mixed_sync_async_tools( - sentry_init, - capture_events, - capture_items, - FastMCP, - stdio, - span_streaming, -): - """Test mixing sync and async tools in FastMCP""" - sentry_init( - integrations=[MCPIntegration()], - traces_sample_rate=1.0, - trace_lifecycle="stream" if span_streaming else "static", + items = capture_items("span") + await stdio( + mcp._mcp_server, + method="tools/call", + params={ + "name": "stdio_tool", + "arguments": {"n": 7}, + }, + request_id="req-stdio", ) - mcp = FastMCP("Test Server") - - @mcp.tool() - def sync_add(a: int, b: int) -> int: - """Sync addition""" - return a + b - - @mcp.tool() - async def async_multiply(x: int, y: int) -> int: - """Async multiplication""" - return x * y - - if span_streaming: - items = capture_items("span") - with sentry_sdk.traces.start_span(name="custom parent"): - # Use async version for both since we're in an async context - await stdio( - mcp._mcp_server, - method="tools/call", - params={ - "name": "sync_add", - "arguments": {"a": 3, "b": 4}, - }, - request_id="req-mixed", - ) - await stdio( - mcp._mcp_server, - method="tools/call", - params={ - "name": "async_multiply", - "arguments": {"x": 5, "y": 6}, - }, - request_id="req-mixed", - ) - - sentry_sdk.flush() - # Verify both sync and async tool spans were created - spans = [item.payload for item in items] - mcp_spans = [ - s for s in spans if s["attributes"].get("sentry.op") == OP.MCP_SERVER - ] - assert len(mcp_spans) == 2 - assert mcp_spans[0]["attributes"][SPANDATA.MCP_TOOL_NAME] == "sync_add" - assert mcp_spans[1]["attributes"][SPANDATA.MCP_TOOL_NAME] == "async_multiply" - else: - events = capture_events() - with start_transaction(name="fastmcp tx"): - # Use async version for both since we're in an async context - await stdio( - mcp._mcp_server, - method="tools/call", - params={ - "name": "sync_add", - "arguments": {"a": 3, "b": 4}, - }, - request_id="req-mixed", - ) - await stdio( - mcp._mcp_server, - method="tools/call", - params={ - "name": "async_multiply", - "arguments": {"x": 5, "y": 6}, - }, - request_id="req-mixed", - ) + sentry_sdk.flush() + # Find MCP spans + spans = [item.payload for item in items] + mcp_spans = [s for s in spans if s["attributes"].get("sentry.op") == OP.MCP_SERVER] - (tx,) = events - assert tx["type"] == "transaction" + assert len(mcp_spans) >= 1 + span = mcp_spans[0] + # Check that stdio transport is detected - # Verify both sync and async tool spans were created - mcp_spans = [s for s in tx["spans"] if s["op"] == OP.MCP_SERVER] - assert len(mcp_spans) == 2 - assert mcp_spans[0]["data"][SPANDATA.MCP_TOOL_NAME] == "sync_add" - assert mcp_spans[1]["data"][SPANDATA.MCP_TOOL_NAME] == "async_multiply" + assert span["attributes"].get(SPANDATA.MCP_TRANSPORT) == "stdio" diff --git a/tests/integrations/mcp/test_mcp.py b/tests/integrations/mcp/test_mcp.py index 1150302ac8..2180db046e 100644 --- a/tests/integrations/mcp/test_mcp.py +++ b/tests/integrations/mcp/test_mcp.py @@ -73,7 +73,6 @@ async def __call__(self, *args, **kwargs): from starlette.testclient import TestClient import sentry_sdk -from sentry_sdk import start_transaction from sentry_sdk.consts import OP, SPANDATA from sentry_sdk.integrations.mcp import MCPIntegration @@ -163,10 +162,7 @@ def test_integration_patches_server(sentry_init): @pytest.mark.skipif( not IS_MCP_V2, reason="Constructor handler registration is MCP v2 only" ) -@pytest.mark.parametrize("span_streaming", [True, False]) -async def test_tool_handler_constructor_registration( - sentry_init, capture_events, capture_items, span_streaming, stdio -): +async def test_tool_handler_constructor_registration(sentry_init, capture_items, stdio): """v2 handlers registered via the Server(...) constructor are instrumented. This is the dominant v2 registration path (used by lowlevel examples and by @@ -175,7 +171,7 @@ async def test_tool_handler_constructor_registration( sentry_init( integrations=[MCPIntegration()], traces_sample_rate=1.0, - trace_lifecycle="stream" if span_streaming else "static", + trace_lifecycle="stream", ) async def test_tool(ctx, params): @@ -190,34 +186,17 @@ async def test_tool(ctx, params): ) server = Server("test-server", on_call_tool=test_tool) - - 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": {"x": 10}}, - request_id="req-ctor", - ) - 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": "calculate", "arguments": {"x": 10}}, - request_id="req-ctor", - ) - (tx,) = events - assert len(tx["spans"]) == 1 - span = tx["spans"][0] - assert span["description"] == "tools/call calculate" - data = span["data"] + items = capture_items("span") + await stdio( + server, + method="tools/call", + params={"name": "calculate", "arguments": {"x": 10}}, + request_id="req-ctor", + ) + sentry_sdk.flush() + span = _find_mcp_span(items, method_name="tools/call") + assert span is not None + data = span["attributes"] assert data[SPANDATA.MCP_TOOL_NAME] == "calculate" assert data[SPANDATA.MCP_METHOD_NAME] == "tools/call" @@ -227,9 +206,8 @@ async def test_tool(ctx, params): @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_high_level_tool_instrumented( - sentry_init, capture_events, capture_items, span_streaming, stdio + sentry_init, capture_items, stdio ): """The in-tree high-level MCPServer wires its handlers through the lowlevel Server(...) constructor, so its tool calls are instrumented too.""" @@ -238,7 +216,7 @@ async def test_mcpserver_high_level_tool_instrumented( sentry_init( integrations=[MCPIntegration()], traces_sample_rate=1.0, - trace_lifecycle="stream" if span_streaming else "static", + trace_lifecycle="stream", ) mcp_server = MCPServer("test-server") @@ -247,54 +225,33 @@ async def test_mcpserver_high_level_tool_instrumented( def add(a: int, b: int) -> int: return a + b - if span_streaming: - items = capture_items("span") - with sentry_sdk.traces.start_span(name="mcp tx"): - await stdio( - mcp_server._lowlevel_server, - method="tools/call", - params={"name": "add", "arguments": {"a": 2, "b": 3}}, - request_id="req-mcpserver", - ) - - sentry_sdk.flush() - span = _find_mcp_span(items, method_name="tools/call") - assert span is not None - assert span["attributes"]["sentry.op"] == OP.MCP_SERVER - assert span["name"] == "tools/call add" - assert span["attributes"][SPANDATA.MCP_METHOD_NAME] == "tools/call" - else: - events = capture_events() - with start_transaction(name="mcp tx"): - await stdio( - mcp_server._lowlevel_server, - method="tools/call", - params={"name": "add", "arguments": {"a": 2, "b": 3}}, - request_id="req-mcpserver", - ) + items = capture_items("span") + await stdio( + mcp_server._lowlevel_server, + method="tools/call", + params={"name": "add", "arguments": {"a": 2, "b": 3}}, + request_id="req-mcpserver", + ) - (tx,) = events - assert len(tx["spans"]) == 1 - span = tx["spans"][0] - assert span["op"] == OP.MCP_SERVER - assert span["description"] == "tools/call add" - assert span["data"][SPANDATA.MCP_METHOD_NAME] == "tools/call" + sentry_sdk.flush() + span = _find_mcp_span(items, method_name="tools/call") + assert span is not None + assert span["attributes"]["sentry.op"] == OP.MCP_SERVER + assert span["name"] == "tools/call add" + assert span["attributes"][SPANDATA.MCP_METHOD_NAME] == "tools/call" @pytest.mark.asyncio @pytest.mark.skipif( not IS_MCP_V2, reason="Constructor handler registration is MCP v2 only" ) -@pytest.mark.parametrize("span_streaming", [True, False]) -async def test_wrapping_handler_is_idempotent( - sentry_init, capture_events, capture_items, span_streaming, stdio -): +async def test_wrapping_handler_is_idempotent(sentry_init, capture_items, stdio): """Re-registering an already-wrapped handler via add_request_handler must not double-wrap — invoking it should produce exactly one MCP span.""" sentry_init( integrations=[MCPIntegration()], traces_sample_rate=1.0, - trace_lifecycle="stream" if span_streaming else "static", + trace_lifecycle="stream", ) async def test_tool(ctx, params): @@ -306,51 +263,34 @@ async def test_tool(ctx, params): server = Server("test-server", on_call_tool=test_tool) entry = server.get_request_handler("tools/call") server.add_request_handler("tools/call", CallToolRequestParams, entry.handler) - - if span_streaming: - items = capture_items("span") - with sentry_sdk.traces.start_span(name="mcp tx"): - await stdio( - server, - method="tools/call", - params={"name": "add", "arguments": {}}, - request_id="req-idempotent", - ) - 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 - ] - else: - events = capture_events() - with start_transaction(name="mcp tx"): - await stdio( - server, - method="tools/call", - params={"name": "add", "arguments": {}}, - request_id="req-idempotent", - ) - (tx,) = events - mcp_spans = [s for s in tx["spans"] if s["op"] == OP.MCP_SERVER] + items = capture_items("span") + await stdio( + server, + method="tools/call", + params={"name": "add", "arguments": {}}, + request_id="req-idempotent", + ) + 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) == 1 @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, capture_items, send_default_pii, include_prompts, - span_streaming, stdio, ): """Test that synchronous tool handlers create proper spans""" @@ -358,7 +298,7 @@ async def test_tool_handler_stdio( integrations=[MCPIntegration(include_prompts=include_prompts)], traces_sample_rate=1.0, send_default_pii=send_default_pii, - trace_lifecycle="stream" if span_streaming else "static", + trace_lifecycle="stream", ) server = Server("test-server") @@ -383,31 +323,17 @@ async def test_tool(ctx, params): async def test_tool(tool_name, arguments): return {"result": "success", "value": 42} - 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", - ) + items = capture_items("span") + result = await stdio( + server, + method="tools/call", + params={ + "name": "calculate", + "arguments": {"x": 10, "y": 5}, + }, + request_id="req-123", + ) + sentry_sdk.flush() if IS_MCP_V2: assert _get_response(result).result["structuredContent"] == { @@ -419,24 +345,12 @@ async def test_tool(tool_name, arguments): {"result": "success", "value": 42}, indent=2, ) - - 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" - data = span["data"] + 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" # Check span data assert data[SPANDATA.MCP_TOOL_NAME] == "calculate" @@ -463,18 +377,15 @@ async def test_tool(tool_name, arguments): @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_streamable_http( sentry_init, - capture_events, capture_items, send_default_pii, include_prompts, - span_streaming, json_rpc, select_transactions_with_mcp_spans, ): @@ -483,7 +394,7 @@ async def test_tool_handler_streamable_http( integrations=[MCPIntegration(include_prompts=include_prompts)], traces_sample_rate=1.0, send_default_pii=send_default_pii, - trace_lifecycle="stream" if span_streaming else "static", + trace_lifecycle="stream", ) server = Server("test-server") @@ -523,60 +434,29 @@ async def test_tool_async(tool_name, arguments): ], lifespan=lambda app: session_manager.run(), ) - - 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", - ) - sentry_sdk.flush() - else: - events = capture_events() - session_id, result = json_rpc( - app, - method="tools/call", - params={ - "name": "process", - "arguments": { - "data": "test", - }, + 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() assert result.json()["result"]["content"][0]["text"] == json.dumps( {"status": "completed"} ) - - 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" - data = span["data"] + 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" # Check span data assert data[SPANDATA.MCP_TOOL_NAME] == "process" @@ -597,13 +477,9 @@ async def test_tool_async(tool_name, arguments): @pytest.mark.asyncio -@pytest.mark.parametrize("span_streaming", [True, False]) async def test_tool_handler_stateless_streamable_http( sentry_init, - capture_events, capture_items, - select_transactions_with_mcp_spans, - span_streaming, ): """A stateless StreamableHTTP server is still reported as the http transport. @@ -613,7 +489,7 @@ async def test_tool_handler_stateless_streamable_http( sentry_init( integrations=[MCPIntegration()], traces_sample_rate=1.0, - trace_lifecycle="stream" if span_streaming else "static", + trace_lifecycle="stream", ) server = Server("test-server") @@ -630,59 +506,31 @@ async def test_tool_async(ctx, params): async def test_tool_async(tool_name, arguments): return [TextContent(type="text", text="ok")] - if span_streaming: - items = capture_items("span") - - # A stateless server accepts each request on its own, so there is no - # handshake to replay and no session id to echo back. - with TestClient(_streamable_http_app(server, stateless=True)) as client: - response = client.post( - "/mcp/", - headers={ - "Accept": "application/json, text/event-stream", - "Content-Type": "application/json", - }, - json={ - "jsonrpc": "2.0", - "method": "tools/call", - "params": {"name": "process", "arguments": {}}, - "id": "req-789", - }, - ) - - assert "mcp-session-id" not in response.headers + items = capture_items("span") - sentry_sdk.flush() - span = _find_mcp_span(items, method_name="tools/call") - assert span is not None - data = span["attributes"] - else: - events = capture_events() - - # A stateless server accepts each request on its own, so there is no - # handshake to replay and no session id to echo back. - with TestClient(_streamable_http_app(server, stateless=True)) as client: - response = client.post( - "/mcp/", - headers={ - "Accept": "application/json, text/event-stream", - "Content-Type": "application/json", - }, - json={ - "jsonrpc": "2.0", - "method": "tools/call", - "params": {"name": "process", "arguments": {}}, - "id": "req-789", - }, - ) + # A stateless server accepts each request on its own, so there is no + # handshake to replay and no session id to echo back. + with TestClient(_streamable_http_app(server, stateless=True)) as client: + response = client.post( + "/mcp/", + headers={ + "Accept": "application/json, text/event-stream", + "Content-Type": "application/json", + }, + json={ + "jsonrpc": "2.0", + "method": "tools/call", + "params": {"name": "process", "arguments": {}}, + "id": "req-789", + }, + ) - assert "mcp-session-id" not in response.headers + assert "mcp-session-id" not in response.headers - transactions = select_transactions_with_mcp_spans( - events, method_name="tools/call" - ) - assert len(transactions) == 1 - data = transactions[0]["spans"][0]["data"] + sentry_sdk.flush() + span = _find_mcp_span(items, method_name="tools/call") + assert span is not None + data = span["attributes"] assert data[SPANDATA.MCP_TRANSPORT] == "http" assert data[SPANDATA.NETWORK_TRANSPORT] == "tcp" @@ -691,15 +539,12 @@ async def test_tool_async(tool_name, arguments): @pytest.mark.asyncio -@pytest.mark.parametrize("span_streaming", [True, False]) -async def test_tool_handler_with_error( - sentry_init, capture_events, capture_items, span_streaming, stdio -): +async def test_tool_handler_with_error(sentry_init, capture_items, stdio): """Test that tool handler errors are captured properly""" sentry_init( integrations=[MCPIntegration()], traces_sample_rate=1.0, - trace_lifecycle="stream" if span_streaming else "static", + trace_lifecycle="stream", ) server = Server("test-server") @@ -716,91 +561,47 @@ async def failing_tool(ctx, params): def failing_tool(tool_name, arguments): raise ValueError("Tool execution failed") - 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() - - resp = _get_response(result) - if IS_MCP_V2: - assert "Tool execution failed" in resp.error.message - else: - assert resp.result["content"][0]["text"] == "Tool execution failed" - - 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" - ) - assert error_payload["exception"]["values"][0]["mechanism"]["type"] == "mcp" - assert not error_payload["exception"]["values"][0]["mechanism"]["handled"] + items = capture_items("event", "span") + result = await stdio( + server, + method="tools/call", + params={ + "name": "bad_tool", + "arguments": {}, + }, + request_id="req-error", + ) + sentry_sdk.flush() - assert span["status"] == "error" + resp = _get_response(result) + if IS_MCP_V2: + assert "Tool execution failed" in resp.error.message 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", - ) - - resp = _get_response(result) - if IS_MCP_V2: - assert "Tool execution failed" in resp.error.message - else: - assert resp.result["content"][0]["text"] == "Tool execution failed" - - # Should have error event and transaction - assert len(events) == 2 - error_event, tx = events + assert resp.result["content"][0]["text"] == "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 error_event["exception"]["values"][0]["mechanism"]["type"] == "mcp" - assert not error_event["exception"]["values"][0]["mechanism"]["handled"] + 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 - # Check transaction and span - assert tx["type"] == "transaction" - assert len(tx["spans"]) == 1 - span = tx["spans"][0] + assert error_payload["level"] == "error" + assert error_payload["exception"]["values"][0]["type"] == "ValueError" + assert error_payload["exception"]["values"][0]["value"] == "Tool execution failed" + assert error_payload["exception"]["values"][0]["mechanism"]["type"] == "mcp" + assert not error_payload["exception"]["values"][0]["mechanism"]["handled"] - assert span["status"] == "internal_error" - assert span["tags"]["status"] == "internal_error" + assert span["status"] == "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, capture_items, send_default_pii, include_prompts, - span_streaming, stdio, ): """Test that synchronous prompt handlers create proper spans""" @@ -808,7 +609,7 @@ async def test_prompt_handler_stdio( integrations=[MCPIntegration(include_prompts=include_prompts)], traces_sample_rate=1.0, send_default_pii=send_default_pii, - trace_lifecycle="stream" if span_streaming else "static", + trace_lifecycle="stream", ) server = Server("test-server") @@ -835,55 +636,29 @@ async def test_prompt(ctx, params): async def test_prompt(name, arguments): return prompt_result - 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", - ) + items = capture_items("span") + result = await stdio( + server, + method="prompts/get", + params={ + "name": "code_help", + "arguments": {"language": "python"}, + }, + request_id="req-prompt", + ) + sentry_sdk.flush() assert _get_response(result).result["messages"][0]["role"] == "user" assert ( _get_response(result).result["messages"][0]["content"]["text"] == "Tell me about Python" ) - - 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" - data = span["data"] + 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" # Check span data assert data[SPANDATA.MCP_PROMPT_NAME] == "code_help" @@ -907,18 +682,15 @@ async def test_prompt(name, arguments): @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_streamable_http( sentry_init, - capture_events, capture_items, send_default_pii, include_prompts, - span_streaming, json_rpc, select_transactions_with_mcp_spans, ): @@ -927,7 +699,7 @@ async def test_prompt_handler_streamable_http( integrations=[MCPIntegration(include_prompts=include_prompts)], traces_sample_rate=1.0, send_default_pii=send_default_pii, - trace_lifecycle="stream" if span_streaming else "static", + trace_lifecycle="stream", ) server = Server("test-server") @@ -970,53 +742,25 @@ async def test_prompt_async(name, arguments): ], lifespan=lambda app: session_manager.run(), ) - - 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", - ) + items = capture_items("span") + _, result = json_rpc( + app, + method="prompts/get", + params={ + "name": "mcp_info", + "arguments": {}, + }, + request_id="req-async-prompt", + ) + sentry_sdk.flush() assert len(result.json()["result"]["messages"]) == 2 - - 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" - data = span["data"] + 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" # For multi-message prompts, count is always captured assert data[SPANDATA.MCP_PROMPT_RESULT_MESSAGE_COUNT] == 2 @@ -1026,15 +770,12 @@ async def test_prompt_async(name, arguments): @pytest.mark.asyncio -@pytest.mark.parametrize("span_streaming", [True, False]) -async def test_prompt_handler_with_error( - sentry_init, capture_events, capture_items, span_streaming, stdio -): +async def test_prompt_handler_with_error(sentry_init, capture_items, stdio): """Test that prompt handler errors are captured""" sentry_init( integrations=[MCPIntegration()], traces_sample_rate=1.0, - trace_lifecycle="stream" if span_streaming else "static", + trace_lifecycle="stream", ) server = Server("test-server") @@ -1053,73 +794,38 @@ async def failing_prompt(ctx, params): async def failing_prompt(name, arguments): raise RuntimeError("Prompt not found") - 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 _get_response(response).error.message == "Prompt not found" - - 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_payload["level"] == "error" - assert error_payload["exception"]["values"][0]["type"] == "RuntimeError" - assert error_payload["exception"]["values"][0]["mechanism"]["type"] == "mcp" - assert not error_payload["exception"]["values"][0]["mechanism"]["handled"] - assert span["status"] == "error" - 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 _get_response(response).error.message == "Prompt not found" - - # Should have error event and transaction - assert len(events) == 2 - error_event, tx = events + items = capture_items("event", "span") + response = await stdio( + server, + method="prompts/get", + params={ + "name": "code_help", + "arguments": {"language": "python"}, + }, + request_id="req-error-prompt", + ) + sentry_sdk.flush() - assert error_event["level"] == "error" - assert error_event["exception"]["values"][0]["type"] == "RuntimeError" - assert error_event["exception"]["values"][0]["mechanism"]["type"] == "mcp" - assert not error_event["exception"]["values"][0]["mechanism"]["handled"] + assert _get_response(response).error.message == "Prompt not found" - # Check transaction and span - assert tx["type"] == "transaction" - assert len(tx["spans"]) == 1 - span = tx["spans"][0] + 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 span["status"] == "internal_error" + assert error_payload["level"] == "error" + assert error_payload["exception"]["values"][0]["type"] == "RuntimeError" + assert error_payload["exception"]["values"][0]["mechanism"]["type"] == "mcp" + assert not error_payload["exception"]["values"][0]["mechanism"]["handled"] + assert span["status"] == "error" @pytest.mark.asyncio -@pytest.mark.parametrize("span_streaming", [True, False]) -async def test_resource_handler_stdio( - sentry_init, capture_events, capture_items, span_streaming, stdio -): +async def test_resource_handler_stdio(sentry_init, capture_items, stdio): """Test that synchronous resource handlers create proper spans""" sentry_init( integrations=[MCPIntegration()], traces_sample_rate=1.0, - trace_lifecycle="stream" if span_streaming else "static", + trace_lifecycle="stream", ) server = Server("test-server") @@ -1151,51 +857,26 @@ async def test_resource(uri): ) ] - 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", - ) + items = capture_items("span") + result = await stdio( + server, + method="resources/read", + params={ + "uri": "file:///path/to/file.txt", + }, + request_id="req-resource", + ) + sentry_sdk.flush() assert _get_response(result).result["contents"][0]["text"] == json.dumps( {"content": "file contents"}, ) - - 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" - data = span["data"] + 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" # Check span data assert data[SPANDATA.MCP_RESOURCE_URI] == "file:///path/to/file.txt" @@ -1208,12 +889,9 @@ async def test_resource(uri): @pytest.mark.asyncio -@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, ): @@ -1221,7 +899,7 @@ async def test_resource_handler_streamable_http( sentry_init( integrations=[MCPIntegration()], traces_sample_rate=1.0, - trace_lifecycle="stream" if span_streaming else "static", + trace_lifecycle="stream", ) server = Server("test-server") @@ -1264,53 +942,26 @@ async def test_resource_async(uri): ], lifespan=lambda app: session_manager.run(), ) - - 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", - ) + 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() assert result.json()["result"]["contents"][0]["text"] == json.dumps( {"data": "resource data"} ) - - 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" - data = span["data"] + 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" assert data[SPANDATA.MCP_RESOURCE_URI] == "https://example.com/resource" assert data[SPANDATA.MCP_RESOURCE_PROTOCOL] == "https" @@ -1318,15 +969,12 @@ async def test_resource_async(uri): @pytest.mark.asyncio -@pytest.mark.parametrize("span_streaming", [True, False]) -async def test_resource_handler_with_error( - sentry_init, capture_events, capture_items, span_streaming, stdio -): +async def test_resource_handler_with_error(sentry_init, capture_items, stdio): """Test that resource handler errors are captured""" sentry_init( integrations=[MCPIntegration()], traces_sample_rate=1.0, - trace_lifecycle="stream" if span_streaming else "static", + trace_lifecycle="stream", ) server = Server("test-server") @@ -1345,70 +993,38 @@ async def failing_resource(ctx, params): def failing_resource(uri): raise FileNotFoundError("Resource not found") - 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() - - 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_payload["level"] == "error" - assert error_payload["exception"]["values"][0]["type"] == "FileNotFoundError" - assert error_payload["exception"]["values"][0]["mechanism"]["type"] == "mcp" - assert not error_payload["exception"]["values"][0]["mechanism"]["handled"] - assert span["status"] == "error" - 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" - assert error_event["exception"]["values"][0]["mechanism"]["type"] == "mcp" - assert not error_event["exception"]["values"][0]["mechanism"]["handled"] + items = capture_items("event", "span") + await stdio( + server, + method="resources/read", + params={ + "uri": "file:///missing.txt", + }, + request_id="req-error-resource", + ) + sentry_sdk.flush() - # Check transaction and span - assert tx["type"] == "transaction" - assert len(tx["spans"]) == 1 - span = tx["spans"][0] + 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 span["status"] == "internal_error" + assert error_payload["level"] == "error" + assert error_payload["exception"]["values"][0]["type"] == "FileNotFoundError" + assert error_payload["exception"]["values"][0]["mechanism"]["type"] == "mcp" + assert not error_payload["exception"]["values"][0]["mechanism"]["handled"] + assert span["status"] == "error" @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, capture_items, send_default_pii, include_prompts, - span_streaming, stdio, ): """Test extraction of tool results from tuple format (UnstructuredContent, StructuredContent)""" @@ -1416,7 +1032,7 @@ async def test_tool_result_extraction_tuple( integrations=[MCPIntegration(include_prompts=include_prompts)], traces_sample_rate=1.0, send_default_pii=send_default_pii, - trace_lifecycle="stream" if span_streaming else "static", + trace_lifecycle="stream", ) server = Server("test-server") @@ -1438,38 +1054,21 @@ def test_tool_tuple(tool_name, arguments): structured = {"key": "value", "count": 5} return (unstructured, structured) - 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() - - 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", - ) + items = capture_items("span") + await stdio( + server, + method="tools/call", + params={ + "name": "calculate", + "arguments": {}, + }, + request_id="req-tuple", + ) + sentry_sdk.flush() - (tx,) = events - data = tx["spans"][0]["data"] + span = _find_mcp_span(items, method_name="tools/call") + assert span is not None + data = span["attributes"] if send_default_pii and include_prompts: assert data[SPANDATA.MCP_TOOL_RESULT_CONTENT] == json.dumps( @@ -1485,18 +1084,15 @@ def test_tool_tuple(tool_name, arguments): @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, capture_items, send_default_pii, include_prompts, - span_streaming, stdio, ): """Test extraction of tool results from UnstructuredContent (list of content blocks)""" @@ -1504,7 +1100,7 @@ async def test_tool_result_extraction_unstructured( integrations=[MCPIntegration(include_prompts=include_prompts)], traces_sample_rate=1.0, send_default_pii=send_default_pii, - trace_lifecycle="stream" if span_streaming else "static", + trace_lifecycle="stream", ) server = Server("test-server") @@ -1531,38 +1127,21 @@ def test_tool_unstructured(tool_name, arguments): MockTextContent("Second part"), ] - 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", - ) + items = capture_items("span") + await stdio( + server, + method="tools/call", + params={ + "name": "text_tool", + "arguments": {}, + }, + request_id="req-unstructured", + ) + sentry_sdk.flush() - (tx,) = events - data = tx["spans"][0]["data"] + span = _find_mcp_span(items, method_name="tools/call") + assert span is not None + data = span["attributes"] # Should extract and join text from content blocks only with PII if send_default_pii and include_prompts: @@ -1572,15 +1151,12 @@ def test_tool_unstructured(tool_name, arguments): @pytest.mark.asyncio -@pytest.mark.parametrize("span_streaming", [True, False]) -async def test_multiple_handlers( - sentry_init, capture_events, capture_items, span_streaming, stdio -): +async def test_multiple_handlers(sentry_init, capture_items, stdio): """Test that multiple handler calls create multiple spans""" sentry_init( integrations=[MCPIntegration()], traces_sample_rate=1.0, - trace_lifecycle="stream" if span_streaming else "static", + trace_lifecycle="stream", ) server = Server("test-server") @@ -1634,12 +1210,8 @@ def prompt1(name, arguments): ], ) - 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") + items = capture_items("span") + tx_ctx = sentry_sdk.traces.start_span(name="mcp tx") with tx_ctx: await stdio( @@ -1671,48 +1243,31 @@ def prompt1(name, arguments): }, request_id="req-multi", ) - - 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 - - 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 + 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 @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, capture_items, send_default_pii, include_prompts, - span_streaming, stdio, ): """Test prompt handler with dict result instead of GetPromptResult object""" @@ -1720,7 +1275,7 @@ async def test_prompt_with_dict_result( integrations=[MCPIntegration(include_prompts=include_prompts)], traces_sample_rate=1.0, send_default_pii=send_default_pii, - trace_lifecycle="stream" if span_streaming else "static", + trace_lifecycle="stream", ) server = Server("test-server") @@ -1750,38 +1305,21 @@ def test_prompt_dict(name, arguments): ] } - 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", - ) + items = capture_items("span") + await stdio( + server, + method="prompts/get", + params={ + "name": "dict_prompt", + "arguments": {}, + }, + request_id="req-dict-prompt", + ) + sentry_sdk.flush() - (tx,) = events - data = tx["spans"][0]["data"] + span = _find_mcp_span(items, method_name="prompts/get") + assert span is not None + data = span["attributes"] # Message count is always captured assert data[SPANDATA.MCP_PROMPT_RESULT_MESSAGE_COUNT] == 1 @@ -1796,15 +1334,12 @@ def test_prompt_dict(name, arguments): @pytest.mark.asyncio -@pytest.mark.parametrize("span_streaming", [True, False]) -async def test_tool_with_complex_arguments( - sentry_init, capture_events, capture_items, span_streaming, stdio -): +async def test_tool_with_complex_arguments(sentry_init, capture_items, stdio): """Test tool handler with complex nested arguments""" sentry_init( integrations=[MCPIntegration()], traces_sample_rate=1.0, - trace_lifecycle="stream" if span_streaming else "static", + trace_lifecycle="stream", ) server = Server("test-server") @@ -1828,39 +1363,21 @@ def test_tool_complex(tool_name, arguments): "string": "test", "number": 42, } + items = capture_items("span") + await stdio( + server, + method="tools/call", + params={ + "name": "complex_tool", + "arguments": complex_args, + }, + request_id="req-complex", + ) + sentry_sdk.flush() - 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() - - 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"] + span = _find_mcp_span(items, method_name="tools/call") + assert span is not None + data = span["attributes"] # Complex arguments should be serialized assert data["mcp.request.argument.nested"] == json.dumps( @@ -1871,16 +1388,13 @@ def test_tool_complex(tool_name, arguments): @pytest.mark.asyncio -@pytest.mark.parametrize("span_streaming", [True, False]) @pytest.mark.skipif(IS_MCP_V2, reason="SSE scope propagation not supported in MCP v2") -async def test_sse_transport_detection( - sentry_init, capture_events, capture_items, span_streaming, json_rpc_sse -): +async def test_sse_transport_detection(sentry_init, capture_items, json_rpc_sse): """Test that SSE transport is correctly detected via query parameter""" sentry_init( integrations=[MCPIntegration()], traces_sample_rate=1.0, - trace_lifecycle="stream" if span_streaming else "static", + trace_lifecycle="stream", ) server = Server("test-server") @@ -1926,10 +1440,7 @@ async def test_tool(ctx, params): async def test_tool(tool_name, arguments): return {"result": "success"} - if span_streaming: - items = capture_items("span") - else: - events = capture_events() + items = capture_items("span") keep_sse_alive = asyncio.Event() app_task, session_id, result = await json_rpc_sse( @@ -1947,21 +1458,10 @@ async def test_tool(tool_name, arguments): await app_task assert result["result"]["structuredContent"] == {"result": "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"] + sentry_sdk.flush() + span = _find_mcp_span(items, method_name="tools/call") + assert span is not None + data = span["attributes"] # Check that SSE transport is detected assert data[SPANDATA.MCP_TRANSPORT] == "sse" @@ -1970,11 +1470,8 @@ async def test_tool(tool_name, arguments): @pytest.mark.asyncio -@pytest.mark.parametrize("span_streaming", [True, False]) @pytest.mark.skipif(not IS_MCP_V2, reason="MCP v2 SSE transport detection") -async def test_sse_transport_detection_v2( - sentry_init, capture_events, capture_items, span_streaming, json_rpc_sse -): +async def test_sse_transport_detection_v2(sentry_init, capture_items, json_rpc_sse): """Test that SSE transport is detected on MCP v2. In v2 the request context is carried by the ServerRequestContext built per @@ -1988,7 +1485,7 @@ async def test_sse_transport_detection_v2( sentry_init( integrations=[MCPIntegration()], traces_sample_rate=1.0, - trace_lifecycle="stream" if span_streaming else "static", + trace_lifecycle="stream", ) server = Server("test-server") @@ -2026,11 +1523,7 @@ async def test_tool(ctx, params): ) server.add_request_handler("tools/call", CallToolRequestParams, test_tool) - - if span_streaming: - items = capture_items("span") - else: - events = capture_events() + items = capture_items("span") keep_sse_alive = asyncio.Event() app_task, session_id, result = await json_rpc_sse( @@ -2048,35 +1541,10 @@ async def test_tool(ctx, params): await app_task assert result["result"]["structuredContent"] == {"result": "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: - # v2 SSE does not propagate Sentry scopes (only StreamableHTTP does), so - # the handler runs without an active transaction and the MCP span becomes - # its own root transaction (data lives on the trace context) rather than a - # child span of the "/sse" request transaction. Accept either shape. - data = None - for event in events: - if event.get("type") != "transaction": - continue - trace = event["contexts"]["trace"] - if ( - trace.get("op") == OP.MCP_SERVER - and trace.get("data", {}).get(SPANDATA.MCP_METHOD_NAME) == "tools/call" - ): - data = trace["data"] - break - for span in event.get("spans", []): - if span["data"].get(SPANDATA.MCP_METHOD_NAME) == "tools/call": - data = span["data"] - break - if data is not None: - break - assert data is not None + sentry_sdk.flush() + span = _find_mcp_span(items, method_name="tools/call") + assert span is not None + data = span["attributes"] assert data[SPANDATA.MCP_TRANSPORT] == "sse" assert data[SPANDATA.NETWORK_TRANSPORT] == "tcp" @@ -2084,10 +1552,7 @@ async def test_tool(ctx, params): @pytest.mark.asyncio -@pytest.mark.parametrize("span_streaming", [True, False]) -async def test_streamable_http_scope_propagation( - sentry_init, capture_events, capture_items, span_streaming, json_rpc -): +async def test_streamable_http_scope_propagation(sentry_init, capture_items, json_rpc): """Errors raised inside an HTTP handler attach to the MCP transaction's trace. StreamableHTTPServerTransport.handle_request stashes the active isolation and @@ -2098,7 +1563,7 @@ async def test_streamable_http_scope_propagation( sentry_init( integrations=[MCPIntegration()], traces_sample_rate=1.0, - trace_lifecycle="stream" if span_streaming else "static", + trace_lifecycle="stream", ) server = Server("test-server") @@ -2127,68 +1592,30 @@ def failing_tool(tool_name, arguments): lifespan=lambda app: session_manager.run(), ) - if span_streaming: - items = capture_items("event", "span") - - json_rpc( - app, - method="tools/call", - params={"name": "bad_tool", "arguments": {}}, - request_id="req-scope", - ) - - (error_event,) = (item.payload for item in items if item.type == "event") - assert error_event["exception"]["values"][0]["type"] == "ValueError" - assert error_event["exception"]["values"][0]["mechanism"]["type"] == "mcp" - assert not error_event["exception"]["values"][0]["mechanism"]["handled"] - - sentry_sdk.flush() - - span = _find_mcp_span(items, method_name="tools/call") - assert span is not None - - # The captured error shares the trace of the MCP span, proving the - # handler executed under the propagated request scope. - assert error_event["contexts"]["trace"]["trace_id"] == span["trace_id"] - else: - events = capture_events() - - json_rpc( - app, - method="tools/call", - params={"name": "bad_tool", "arguments": {}}, - request_id="req-scope", - ) + items = capture_items("event", "span") + json_rpc( + app, + method="tools/call", + params={"name": "bad_tool", "arguments": {}}, + request_id="req-scope", + ) - error_events = [e for e in events if e.get("type") != "transaction"] - mcp_transactions = [ - e - for e in events - if e.get("type") == "transaction" - and any( - span["data"].get(SPANDATA.MCP_METHOD_NAME) == "tools/call" - for span in e.get("spans", []) - ) - ] + (error_event,) = (item.payload for item in items if item.type == "event") + assert error_event["exception"]["values"][0]["type"] == "ValueError" + assert error_event["exception"]["values"][0]["mechanism"]["type"] == "mcp" + assert not error_event["exception"]["values"][0]["mechanism"]["handled"] - assert len(error_events) == 1 - assert len(mcp_transactions) == 1 + sentry_sdk.flush() - error_event = error_events[0] - assert error_event["exception"]["values"][0]["type"] == "ValueError" - assert error_event["exception"]["values"][0]["mechanism"]["type"] == "mcp" - assert not error_event["exception"]["values"][0]["mechanism"]["handled"] + span = _find_mcp_span(items, method_name="tools/call") + assert span is not None - # The captured error shares the trace of the MCP transaction, proving the - # handler executed under the propagated request scope. - assert ( - error_event["contexts"]["trace"]["trace_id"] - == mcp_transactions[0]["contexts"]["trace"]["trace_id"] - ) + # The captured error shares the trace of the MCP span, proving the + # handler executed under the propagated request scope. + assert error_event["contexts"]["trace"]["trace_id"] == span["trace_id"] @pytest.mark.asyncio -@pytest.mark.parametrize("span_streaming", [True, False]) @pytest.mark.parametrize( "data_collection, send_default_pii, expect_input", [ @@ -2232,19 +1659,17 @@ def failing_tool(tool_name, arguments): ) async def test_tool_data_collection_inputs( sentry_init, - capture_events, capture_items, data_collection, send_default_pii, expect_input, - span_streaming, stdio, ): init_kwargs = { "integrations": [MCPIntegration()], "traces_sample_rate": 1.0, "send_default_pii": send_default_pii, - "trace_lifecycle": "stream" if span_streaming else "static", + "trace_lifecycle": "stream", } if data_collection is not None: init_kwargs["_experiments"] = {"data_collection": data_collection} @@ -2272,21 +1697,12 @@ async def test_tool(tool_name, arguments): "name": "calculate", "arguments": {"x": 10, "y": 5}, } - - if span_streaming: - items = capture_items("span") - with sentry_sdk.traces.start_span(name="mcp tx"): - await stdio(server, method="tools/call", params=params, request_id="req-1") - 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=params, request_id="req-1") - (tx,) = events - data = tx["spans"][0]["data"] + items = capture_items("span") + await stdio(server, method="tools/call", params=params, request_id="req-1") + sentry_sdk.flush() + span = _find_mcp_span(items, method_name="tools/call") + assert span is not None + data = span["attributes"] # Arguments are only gated once data_collection is configured; without it they # are set unconditionally, as they were before data_collection existed. @@ -2305,7 +1721,6 @@ async def test_tool(tool_name, arguments): @pytest.mark.asyncio -@pytest.mark.parametrize("span_streaming", [True, False]) @pytest.mark.parametrize( "data_collection, send_default_pii, expect_output", [ @@ -2349,12 +1764,10 @@ async def test_tool(tool_name, arguments): ) async def test_tool_data_collection_outputs( sentry_init, - capture_events, capture_items, data_collection, send_default_pii, expect_output, - span_streaming, stdio, ): """Tool result content is gated on data_collection.gen_ai.outputs""" @@ -2362,7 +1775,7 @@ async def test_tool_data_collection_outputs( "integrations": [MCPIntegration()], "traces_sample_rate": 1.0, "send_default_pii": send_default_pii, - "trace_lifecycle": "stream" if span_streaming else "static", + "trace_lifecycle": "stream", } if data_collection is not None: init_kwargs["_experiments"] = {"data_collection": data_collection} @@ -2395,21 +1808,12 @@ async def test_tool(tool_name, arguments): "name": "calculate", "arguments": {"x": 10, "y": 5}, } - - if span_streaming: - items = capture_items("span") - with sentry_sdk.traces.start_span(name="mcp tx"): - await stdio(server, method="tools/call", params=params, request_id="req-1") - 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=params, request_id="req-1") - (tx,) = events - data = tx["spans"][0]["data"] + items = capture_items("span") + await stdio(server, method="tools/call", params=params, request_id="req-1") + sentry_sdk.flush() + span = _find_mcp_span(items, method_name="tools/call") + assert span is not None + data = span["attributes"] if expect_output: assert data[SPANDATA.MCP_TOOL_RESULT_CONTENT] == json.dumps( @@ -2422,7 +1826,6 @@ async def test_tool(tool_name, arguments): @pytest.mark.asyncio -@pytest.mark.parametrize("span_streaming", [True, False]) @pytest.mark.parametrize( "data_collection, send_default_pii, expect_input", [ @@ -2466,12 +1869,10 @@ async def test_tool(tool_name, arguments): ) async def test_prompt_data_collection_inputs( sentry_init, - capture_events, capture_items, data_collection, send_default_pii, expect_input, - span_streaming, stdio, ): """Prompt arguments and message content are gated on data_collection.gen_ai.inputs. @@ -2483,7 +1884,7 @@ async def test_prompt_data_collection_inputs( "integrations": [MCPIntegration()], "traces_sample_rate": 1.0, "send_default_pii": send_default_pii, - "trace_lifecycle": "stream" if span_streaming else "static", + "trace_lifecycle": "stream", } if data_collection is not None: init_kwargs["_experiments"] = {"data_collection": data_collection} @@ -2518,25 +1919,12 @@ async def test_prompt(name, arguments): "name": "code_help", "arguments": {"language": "python"}, } - - if span_streaming: - items = capture_items("span") - with sentry_sdk.traces.start_span(name="mcp tx"): - await stdio( - server, method="prompts/get", params=params, request_id="req-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=params, request_id="req-prompt" - ) - (tx,) = events - data = tx["spans"][0]["data"] + items = capture_items("span") + await stdio(server, method="prompts/get", params=params, request_id="req-prompt") + sentry_sdk.flush() + span = _find_mcp_span(items, method_name="prompts/get") + assert span is not None + data = span["attributes"] # Arguments are only gated once data_collection is configured; without it they # are set unconditionally, as they were before data_collection existed. @@ -2559,19 +1947,16 @@ async def test_prompt(name, arguments): @pytest.mark.asyncio -@pytest.mark.parametrize("span_streaming", [True, False]) async def test_include_prompts_ignored_when_data_collection_set( sentry_init, - capture_events, capture_items, - span_streaming, stdio, ): sentry_init( integrations=[MCPIntegration(include_prompts=False)], traces_sample_rate=1.0, send_default_pii=True, - trace_lifecycle="stream" if span_streaming else "static", + trace_lifecycle="stream", _experiments={"data_collection": {"gen_ai": {"outputs": True}}}, ) @@ -2593,21 +1978,12 @@ async def test_tool(tool_name, arguments): return {"value": 42} params = {"name": "calculate", "arguments": {"x": 10}} - - if span_streaming: - items = capture_items("span") - with sentry_sdk.traces.start_span(name="mcp tx"): - await stdio(server, method="tools/call", params=params, request_id="req-1") - 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=params, request_id="req-1") - (tx,) = events - data = tx["spans"][0]["data"] + items = capture_items("span") + await stdio(server, method="tools/call", params=params, request_id="req-1") + sentry_sdk.flush() + span = _find_mcp_span(items, method_name="tools/call") + assert span is not None + data = span["attributes"] assert data[SPANDATA.MCP_TOOL_RESULT_CONTENT] == json.dumps({"value": 42}) assert data[SPANDATA.MCP_TOOL_RESULT_CONTENT_COUNT] == 1