From 63cc4b724d21ed161bcf583bba644f50bb610268 Mon Sep 17 00:00:00 2001 From: Alexander Alderman Webb Date: Mon, 7 Sep 2026 11:22:32 +0200 Subject: [PATCH 01/10] chore(mcp): Remove transaction-based tracing --- sentry_sdk/integrations/mcp.py | 775 ++++++++---------- tests/integrations/mcp/test_mcp.py | 1180 ++++++++-------------------- 2 files changed, 684 insertions(+), 1271 deletions(-) diff --git a/sentry_sdk/integrations/mcp.py b/sentry_sdk/integrations/mcp.py index 56038cf54a..4edc2052fa 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 ( has_data_collection_enabled, package_version, @@ -352,78 +351,65 @@ 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: + sentry_sdk.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: - sentry_sdk.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 @@ -454,81 +440,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, - ) + 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, + ) - try: - result = await call_next(ctx) + try: + result = await call_next(ctx) - except Exception as e: - sentry_sdk.capture_exception(e) - raise + except Exception as e: + sentry_sdk.capture_exception(e) + raise - if not isinstance(result, dict): - return result + if not isinstance(result, dict): + return result - # Get integration to check PII settings - integration = client.get_integration(MCPIntegration) - if integration 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"]["outputs"]: - should_include_result_data = True - elif should_send_default_pii() and integration.include_prompts: + # 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 @@ -573,130 +546,117 @@ 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 - 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: - sentry_sdk.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: + except Exception as e: + sentry_sdk.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: + 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 @@ -729,104 +689,91 @@ 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 _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: + result = await call_next(ctx) + except Exception as e: + sentry_sdk.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 - try: - result = await call_next(ctx) - except Exception as e: - sentry_sdk.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: + # 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 + + if result.get("messages"): + messages = result["messages"] + message_count = len(messages) - # For prompts, count messages and set role/content only for single-message prompts - try: - messages: "Optional[list[dict[str, Any]]]" = None - 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 + ) - if result.get("messages"): - messages = result["messages"] - message_count = len(messages) + # 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"] - # Always set message count if we found messages - if message_count > 0: + 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 @@ -864,64 +811,51 @@ 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") + 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) + 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) + 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 + result = func(*original_args, **original_kwargs) + if force_await or inspect.isawaitable(result): + result = await result - except Exception as e: - sentry_sdk.capture_exception(e) - raise + except Exception as e: + sentry_sdk.capture_exception(e) + raise return result @@ -942,51 +876,38 @@ 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: - sentry_sdk.capture_exception(e) - raise + except Exception as e: + sentry_sdk.capture_exception(e) + raise return result diff --git a/tests/integrations/mcp/test_mcp.py b/tests/integrations/mcp/test_mcp.py index 21ec6ba0d0..df701cd9bc 100644 --- a/tests/integrations/mcp/test_mcp.py +++ b/tests/integrations/mcp/test_mcp.py @@ -163,10 +163,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 +172,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 +187,18 @@ 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") + 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"] assert data[SPANDATA.MCP_TOOL_NAME] == "calculate" assert data[SPANDATA.MCP_METHOD_NAME] == "tools/call" @@ -266,16 +247,13 @@ def add(a: int, b: int) -> int: @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): @@ -287,51 +265,35 @@ 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") + 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 + ] 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""" @@ -339,7 +301,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") @@ -364,31 +326,18 @@ 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") + 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() if IS_MCP_V2: assert _get_response(result).result["structuredContent"] == { @@ -400,24 +349,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" @@ -444,18 +381,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, ): @@ -464,7 +398,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") @@ -504,60 +438,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" @@ -639,15 +542,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") @@ -664,87 +564,46 @@ 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" + items = capture_items("event", "span") + with sentry_sdk.traces.start_span(name="mcp tx"): + result = await stdio( + server, + method="tools/call", + params={ + "name": "bad_tool", + "arguments": {}, + }, + request_id="req-error", ) + sentry_sdk.flush() - assert 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" + assert resp.result["content"][0]["text"] == "Tool execution failed" - # Should have error event and transaction - assert len(events) == 2 - error_event, tx = events + error_payload = next(item.payload for item in items if item.type == "event") + span = _find_mcp_span(items, method_name="tools/call") + assert span is not None - # 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_payload["level"] == "error" + assert error_payload["exception"]["values"][0]["type"] == "ValueError" + assert error_payload["exception"]["values"][0]["value"] == "Tool execution failed" - # Check transaction and span - assert tx["type"] == "transaction" - assert len(tx["spans"]) == 1 - span = tx["spans"][0] - - 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""" @@ -752,7 +611,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") @@ -779,55 +638,30 @@ 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") + 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() 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" @@ -851,18 +685,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, ): @@ -871,7 +702,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") @@ -914,53 +745,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 @@ -970,15 +773,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") @@ -997,69 +797,37 @@ 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 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") + 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 error_event["level"] == "error" - assert error_event["exception"]["values"][0]["type"] == "RuntimeError" + 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 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") @@ -1091,51 +859,27 @@ 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") + 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() 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" @@ -1148,12 +892,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, ): @@ -1161,7 +902,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") @@ -1204,53 +945,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" @@ -1258,15 +972,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") @@ -1285,66 +996,37 @@ 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 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" + 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() - # 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 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)""" @@ -1352,7 +1034,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") @@ -1374,38 +1056,22 @@ 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") + with sentry_sdk.traces.start_span(name="mcp tx"): + await stdio( + server, + method="tools/call", + params={ + "name": "calculate", + "arguments": {}, + }, + request_id="req-tuple", + ) + sentry_sdk.flush() - (tx,) = events - 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( @@ -1421,18 +1087,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)""" @@ -1440,7 +1103,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") @@ -1467,38 +1130,22 @@ 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") + 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() - (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: @@ -1508,15 +1155,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") @@ -1570,12 +1214,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( @@ -1607,48 +1247,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""" @@ -1656,7 +1279,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") @@ -1686,38 +1309,22 @@ 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") + 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() - (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 @@ -1732,15 +1339,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") @@ -1764,39 +1368,22 @@ def test_tool_complex(tool_name, arguments): "string": "test", "number": 42, } + 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() - 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( @@ -1807,16 +1394,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") @@ -1862,10 +1446,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( @@ -1883,21 +1464,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" @@ -1906,11 +1476,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 @@ -1924,7 +1491,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") @@ -1962,11 +1529,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( @@ -1984,35 +1547,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" @@ -2093,7 +1631,6 @@ def failing_tool(tool_name, arguments): @pytest.mark.asyncio -@pytest.mark.parametrize("span_streaming", [True, False]) @pytest.mark.parametrize( "data_collection, send_default_pii, expect_input", [ @@ -2137,19 +1674,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} @@ -2177,21 +1712,13 @@ 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") + 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"] # Arguments are only gated once data_collection is configured; without it they # are set unconditionally, as they were before data_collection existed. @@ -2210,7 +1737,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", [ @@ -2254,12 +1780,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""" @@ -2267,7 +1791,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} @@ -2300,21 +1824,13 @@ 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") + 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"] if expect_output: assert data[SPANDATA.MCP_TOOL_RESULT_CONTENT] == json.dumps( @@ -2327,7 +1843,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", [ @@ -2371,12 +1886,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. @@ -2388,7 +1901,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} @@ -2423,25 +1936,15 @@ 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") + 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"] # Arguments are only gated once data_collection is configured; without it they # are set unconditionally, as they were before data_collection existed. @@ -2464,19 +1967,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}}}, ) @@ -2498,21 +1998,13 @@ 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") + 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"] assert data[SPANDATA.MCP_TOOL_RESULT_CONTENT] == json.dumps({"value": 42}) assert data[SPANDATA.MCP_TOOL_RESULT_CONTENT_COUNT] == 1 From 24bb81eab3ff2a17665b99124b9566f05a698fe3 Mon Sep 17 00:00:00 2001 From: Alexander Alderman Webb Date: Mon, 7 Sep 2026 11:42:31 +0200 Subject: [PATCH 02/10] restore whitespace --- sentry_sdk/integrations/mcp.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/sentry_sdk/integrations/mcp.py b/sentry_sdk/integrations/mcp.py index 84f570e18f..47d5f989c9 100644 --- a/sentry_sdk/integrations/mcp.py +++ b/sentry_sdk/integrations/mcp.py @@ -345,6 +345,7 @@ async def _tool_handler_wrapper( 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) @@ -547,6 +548,7 @@ async def _prompt_handler_wrapper( 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) @@ -829,6 +831,7 @@ async def _resource_handler_wrapper( 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) From e81bcb6dab1a302ec9e1cbc98901079929fb023d Mon Sep 17 00:00:00 2001 From: Alexander Alderman Webb Date: Mon, 7 Sep 2026 11:43:21 +0200 Subject: [PATCH 03/10] restore whitespace 2 --- sentry_sdk/integrations/mcp.py | 1 + 1 file changed, 1 insertion(+) diff --git a/sentry_sdk/integrations/mcp.py b/sentry_sdk/integrations/mcp.py index 47d5f989c9..1d0bb6927f 100644 --- a/sentry_sdk/integrations/mcp.py +++ b/sentry_sdk/integrations/mcp.py @@ -884,6 +884,7 @@ async def _instrument_v2_resource_read( try: result = await call_next(ctx) + except Exception as e: with capture_internal_exceptions(): _capture_exception(e) From 8286f39bca047ba459ffaf21396e27c3af49647f Mon Sep 17 00:00:00 2001 From: Alexander Alderman Webb Date: Mon, 7 Sep 2026 13:00:51 +0200 Subject: [PATCH 04/10] first pass over fastmcp tests --- tests/integrations/fastmcp/test_fastmcp.py | 1229 ++++++-------------- 1 file changed, 374 insertions(+), 855 deletions(-) diff --git a/tests/integrations/fastmcp/test_fastmcp.py b/tests/integrations/fastmcp/test_fastmcp.py index 4d3784e135..2f8d1e9323 100644 --- a/tests/integrations/fastmcp/test_fastmcp.py +++ b/tests/integrations/fastmcp/test_fastmcp.py @@ -284,23 +284,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 +307,39 @@ 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") + 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" + 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", + ) - # 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) == 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" + + # 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 +348,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 +384,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", - ) - - 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] + items = capture_items("span") - 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 +441,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,81 +451,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 + 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", + ) - # 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] + 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 + 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" + # 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_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", + trace_lifecycle="stream", ) mcp = FastMCP("Test Server") @@ -629,138 +511,76 @@ def tool_three(z: int) -> int: """Third tool""" return z - 5 - 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", - ) + 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"] - ) - }, + 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", - ) + }, + 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"] - ) - }, + 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" + }, + request_id="req-multi", + ) - # 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" + 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" @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 +595,27 @@ 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") + 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", + ) - # 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 +629,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 +666,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"] - ) - 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" + 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", + ) - # 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" + 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["data"] - else: - assert SPANDATA.MCP_PROMPT_RESULT_MESSAGE_CONTENT not in span["data"] + # 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"] @pytest.mark.parametrize("FastMCP", fastmcp_implementations, ids=fastmcp_ids) @@ -1020,20 +779,17 @@ async def async_prompt(topic: 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") @@ -1046,67 +802,36 @@ 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", + 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}" ) - 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" + 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( @@ -1115,21 +840,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") @@ -1148,78 +870,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") + 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 - - 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() + _, 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 - @mcp.resource("https://example.com/{resource}") - async def read_url(resource: str): - """Read a URL resource""" - return "resource data" + assert "resource data" in result.json()["result"]["contents"][0]["text"] - _, 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"] - - 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" # ============================================================================= @@ -1229,20 +915,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") @@ -1252,51 +935,27 @@ 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") + 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", + ) - (tx,) = events + sentry_sdk.flush() - assert tx["contexts"]["trace"]["origin"] == "manual" + 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 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" # ============================================================================= @@ -1306,24 +965,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") @@ -1362,93 +1018,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") @@ -1470,80 +1083,49 @@ def http_tool(data: str) -> dict: """Tool for HTTP transport test""" return {"processed": data.upper()} - if span_streaming: - 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() + items = capture_items("span") - _, 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") @@ -1553,54 +1135,28 @@ 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 + 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", + ) - # 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 stdio transport is detected + assert len(mcp_spans) >= 1 + span = mcp_spans[0] + # Check that stdio transport is detected - assert span["data"].get(SPANDATA.MCP_TRANSPORT) == "stdio" + assert span["attributes"].get(SPANDATA.MCP_TRANSPORT) == "stdio" # ============================================================================= @@ -1743,20 +1299,17 @@ def none_return_tool(action: str) -> None: @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", + trace_lifecycle="stream", ) mcp = FastMCP("Test Server") @@ -1771,66 +1324,32 @@ 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", - ) - - (tx,) = events - assert tx["type"] == "transaction" + 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", + ) - # 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" + 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" From e19d9f405c58dd5c84a79adf2c8b41555ab5e639 Mon Sep 17 00:00:00 2001 From: Alexander Alderman Webb Date: Tue, 8 Sep 2026 11:35:43 +0200 Subject: [PATCH 05/10] remove test_fastmcp_prompt_async --- tests/integrations/fastmcp/test_fastmcp.py | 71 ---------------------- 1 file changed, 71 deletions(-) diff --git a/tests/integrations/fastmcp/test_fastmcp.py b/tests/integrations/fastmcp/test_fastmcp.py index 2f8d1e9323..54c08a0086 100644 --- a/tests/integrations/fastmcp/test_fastmcp.py +++ b/tests/integrations/fastmcp/test_fastmcp.py @@ -697,77 +697,6 @@ def code_help_prompt(language: str): assert SPANDATA.MCP_PROMPT_RESULT_MESSAGE_CONTENT not in span["attributes"] -@pytest.mark.parametrize("FastMCP", fastmcp_implementations, ids=fastmcp_ids) -@pytest.mark.asyncio -async def test_fastmcp_prompt_async( - sentry_init, - capture_events, - FastMCP, - json_rpc, - select_transactions_with_mcp_spans, -): - """Test that FastMCP async prompt handlers create proper spans""" - sentry_init( - integrations=[MCPIntegration()], - traces_sample_rate=1.0, - ) - events = capture_events() - - mcp = FastMCP("Test Server") - - session_manager = StreamableHTTPSessionManager( - app=mcp._mcp_server, - json_response=True, - ) - - app = Starlette( - routes=[ - Mount("/mcp", app=session_manager.handle_request), - ], - lifespan=lambda app: session_manager.run(), - ) - - # Try to register an async prompt handler - if hasattr(mcp, "prompt"): - - @mcp.prompt() - async def async_prompt(topic: str): - """Get async prompt for a topic""" - message1 = { - "role": "user", - "content": {"type": "text", "text": f"What is {topic}?"}, - } - - message2 = { - "role": "assistant", - "content": { - "type": "text", - "text": "Let me explain that", - }, - } - - if FASTMCP_VERSION is not None and FASTMCP_VERSION >= (3,): - message1 = Message(message1) - message2 = Message(message2) - - return [message1, message2] - - json_rpc( - app, - method="prompts/get", - params={ - "name": "async_prompt", - "arguments": {"topic": "MCP"}, - }, - request_id="req-async-prompt", - ) - - transactions = select_transactions_with_mcp_spans( - events, method_name="prompts/get" - ) - assert len(transactions) == 1 - - # ============================================================================= # Resource Handler Tests (if supported) # ============================================================================= From c6a446cd5d7bdecffee4e2bbe0028ab86ef4f789 Mon Sep 17 00:00:00 2001 From: Alexander Alderman Webb Date: Tue, 8 Sep 2026 11:40:05 +0200 Subject: [PATCH 06/10] adapt tests that were not parametrized --- tests/integrations/mcp/test_mcp.py | 61 +++++++++++++----------------- 1 file changed, 26 insertions(+), 35 deletions(-) diff --git a/tests/integrations/mcp/test_mcp.py b/tests/integrations/mcp/test_mcp.py index 6bac92b8d0..6131b3fd6d 100644 --- a/tests/integrations/mcp/test_mcp.py +++ b/tests/integrations/mcp/test_mcp.py @@ -209,7 +209,7 @@ async def test_tool(ctx, params): @pytest.mark.asyncio @pytest.mark.skipif(not IS_MCP_V2, reason="MCPServer is MCP v2 only") async def test_mcpserver_high_level_tool_instrumented( - sentry_init, capture_events, 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.""" @@ -218,6 +218,7 @@ async def test_mcpserver_high_level_tool_instrumented( sentry_init( integrations=[MCPIntegration()], traces_sample_rate=1.0, + trace_lifecycle="stream", ) mcp_server = MCPServer("test-server") @@ -226,7 +227,7 @@ async def test_mcpserver_high_level_tool_instrumented( def add(a: int, b: int) -> int: return a + b - events = capture_events() + items = capture_items("span") with start_transaction(name="mcp tx"): await stdio( mcp_server._lowlevel_server, @@ -235,12 +236,12 @@ def add(a: int, b: int) -> int: 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 @@ -483,7 +484,7 @@ async def test_tool_async(tool_name, arguments): @pytest.mark.asyncio async def test_tool_handler_stateless_streamable_http( sentry_init, - capture_events, + capture_items, select_transactions_with_mcp_spans, ): """A stateless StreamableHTTP server is still reported as the http transport. @@ -494,6 +495,7 @@ async def test_tool_handler_stateless_streamable_http( sentry_init( integrations=[MCPIntegration()], traces_sample_rate=1.0, + trace_lifecycle="stream", ) server = Server("test-server") @@ -510,7 +512,7 @@ async def test_tool_async(ctx, params): async def test_tool_async(tool_name, arguments): return [TextContent(type="text", text="ok")] - events = capture_events() + 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. @@ -531,9 +533,10 @@ async def test_tool_async(tool_name, arguments): 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" @@ -1564,7 +1567,7 @@ async def test_tool(ctx, params): @pytest.mark.asyncio -async def test_streamable_http_scope_propagation(sentry_init, capture_events, 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 @@ -1603,7 +1606,7 @@ def failing_tool(tool_name, arguments): lifespan=lambda app: session_manager.run(), ) - events = capture_events() + items = capture_items("event", "span") json_rpc( app, method="tools/call", @@ -1611,31 +1614,19 @@ def failing_tool(tool_name, 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", []) - ) - ] - - assert len(error_events) == 1 - assert len(mcp_transactions) == 1 - - error_event = error_events[0] + (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"] - # The captured error shares the trace of the MCP transaction, proving the + 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"] - == mcp_transactions[0]["contexts"]["trace"]["trace_id"] - ) + assert error_event["contexts"]["trace"]["trace_id"] == span["trace_id"] @pytest.mark.asyncio From 2df877e4138ce578747a0b786555f6ffd1dbf05b Mon Sep 17 00:00:00 2001 From: Alexander Alderman Webb Date: Tue, 8 Sep 2026 11:44:06 +0200 Subject: [PATCH 07/10] . --- tests/integrations/mcp/test_mcp.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/integrations/mcp/test_mcp.py b/tests/integrations/mcp/test_mcp.py index 6131b3fd6d..5f19f200f0 100644 --- a/tests/integrations/mcp/test_mcp.py +++ b/tests/integrations/mcp/test_mcp.py @@ -1578,6 +1578,7 @@ async def test_streamable_http_scope_propagation(sentry_init, capture_items, jso sentry_init( integrations=[MCPIntegration()], traces_sample_rate=1.0, + trace_lifecycle="stream", ) server = Server("test-server") From 44523f3b8ca6184e8128873b2df4a37e37d8a11b Mon Sep 17 00:00:00 2001 From: Alexander Alderman Webb Date: Tue, 8 Sep 2026 13:19:05 +0200 Subject: [PATCH 08/10] remove manual spans --- tests/integrations/fastmcp/test_fastmcp.py | 400 +++++++-------------- tests/integrations/mcp/test_mcp.py | 240 ++++++------- 2 files changed, 236 insertions(+), 404 deletions(-) diff --git a/tests/integrations/fastmcp/test_fastmcp.py b/tests/integrations/fastmcp/test_fastmcp.py index 54c08a0086..bba72a5fdc 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 @@ -309,21 +308,20 @@ def add_numbers(a: int, b: int) -> dict: 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", - ) + # 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 + assert len(spans) == 1 # Verify span structure span = spans[0] @@ -452,16 +450,15 @@ def failing_tool(value: int) -> int: raise ValueError("Tool execution failed") 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", - ) + 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 @@ -512,50 +509,47 @@ def tool_three(z: int) -> int: return z - 5 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", - ) + 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"] - ) - }, + 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", - ) + }, + 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"] - ) - }, + 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", - ) + }, + request_id="req-multi", + ) sentry_sdk.flush() # Verify three spans were created @@ -596,16 +590,15 @@ def get_user_data(user_id: int) -> dict: } 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", - ) + 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 @@ -667,16 +660,15 @@ def code_help_prompt(language: str): return [message] 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", - ) + 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 @@ -732,23 +724,20 @@ def read_file(path: str): return "file contents" 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 + 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 @@ -865,21 +854,19 @@ def test_tool(value: int) -> int: return value * 2 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", - ) + 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] @@ -1065,16 +1052,15 @@ def stdio_tool(n: int) -> dict: return {"squared": n * n} 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", - ) + 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 @@ -1088,144 +1074,11 @@ def stdio_tool(n: int) -> dict: assert span["attributes"].get(SPANDATA.MCP_TRANSPORT) == "stdio" -# ============================================================================= -# Integration-specific Tests -# ============================================================================= - - -@pytest.mark.skipif(not HAS_MCP_FASTMCP, reason="mcp.server.fastmcp not installed") -def test_mcp_fastmcp_specific_features(sentry_init, capture_events): - """Test features specific to mcp.server.fastmcp (from mcp package)""" - sentry_init( - integrations=[MCPIntegration()], - traces_sample_rate=1.0, - ) - events = capture_events() - - from mcp.server.fastmcp import FastMCP - - mcp = FastMCP("MCP Package Server") - - @mcp.tool() - def package_specific_tool(x: int) -> int: - """Tool for mcp.server.fastmcp package""" - return x + 100 - - with start_transaction(name="mcp.server.fastmcp tx"): - result = call_tool_through_mcp(mcp, "package_specific_tool", {"x": 50}) - - assert result["result"] == 150 - - (tx,) = events - assert tx["type"] == "transaction" - - -@pytest.mark.asyncio -@pytest.mark.skipif( - not HAS_STANDALONE_FASTMCP, reason="standalone fastmcp not installed" -) -async def test_standalone_fastmcp_specific_features(sentry_init, capture_events, stdio): - """Test features specific to standalone fastmcp package""" - sentry_init( - integrations=[MCPIntegration()], - traces_sample_rate=1.0, - ) - events = capture_events() - - from fastmcp import FastMCP - - mcp = FastMCP("Standalone FastMCP Server") - - @mcp.tool() - def standalone_specific_tool(message: str) -> dict: - """Tool for standalone fastmcp package""" - return {"echo": message, "length": len(message)} - - with start_transaction(name="standalone fastmcp tx"): - await stdio( - mcp._mcp_server, - method="tools/call", - params={ - "name": "standalone_specific_tool", - "arguments": {"message": "Hello FastMCP"}, - }, - ) - - (tx,) = events - assert tx["type"] == "transaction" - - # ============================================================================= # Edge Cases and Robustness Tests # ============================================================================= -@pytest.mark.asyncio -@pytest.mark.parametrize("FastMCP", fastmcp_implementations, ids=fastmcp_ids) -async def test_fastmcp_tool_with_no_arguments( - sentry_init, capture_events, FastMCP, stdio -): - """Test FastMCP tool with no arguments""" - sentry_init( - integrations=[MCPIntegration()], - traces_sample_rate=1.0, - ) - events = capture_events() - - mcp = FastMCP("Test Server") - - @mcp.tool() - def no_args_tool() -> str: - """Tool that takes no arguments""" - return "success" - - with start_transaction(name="fastmcp tx"): - await stdio( - mcp._mcp_server, - method="tools/call", - params={ - "name": "no_args_tool", - "arguments": {}, - }, - ) - - (tx,) = events - assert tx["type"] == "transaction" - - -@pytest.mark.asyncio -@pytest.mark.parametrize("FastMCP", fastmcp_implementations, ids=fastmcp_ids) -async def test_fastmcp_tool_with_none_return( - sentry_init, capture_events, FastMCP, stdio -): - """Test FastMCP tool that returns None""" - sentry_init( - integrations=[MCPIntegration()], - traces_sample_rate=1.0, - ) - events = capture_events() - - mcp = FastMCP("Test Server") - - @mcp.tool() - def none_return_tool(action: str) -> None: - """Tool that returns None""" - pass - - with start_transaction(name="fastmcp tx"): - await stdio( - mcp._mcp_server, - method="tools/call", - params={ - "name": "none_return_tool", - "arguments": {"action": "log"}, - }, - ) - - (tx,) = events - assert tx["type"] == "transaction" - - @pytest.mark.asyncio @pytest.mark.parametrize("FastMCP", fastmcp_implementations, ids=fastmcp_ids) async def test_fastmcp_mixed_sync_async_tools( @@ -1254,26 +1107,25 @@ async def async_multiply(x: int, y: int) -> int: return x * y 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", - ) + # 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 diff --git a/tests/integrations/mcp/test_mcp.py b/tests/integrations/mcp/test_mcp.py index 5f19f200f0..5d47e00da8 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 @@ -188,13 +187,12 @@ async def test_tool(ctx, params): server = Server("test-server", on_call_tool=test_tool) 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", - ) + 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 @@ -228,13 +226,12 @@ def add(a: int, b: int) -> int: return a + b items = capture_items("span") - 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", - ) + 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") @@ -267,13 +264,12 @@ async def test_tool(ctx, params): entry = server.get_request_handler("tools/call") server.add_request_handler("tools/call", CallToolRequestParams, entry.handler) 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", - ) + await stdio( + server, + method="tools/call", + params={"name": "add", "arguments": {}}, + request_id="req-idempotent", + ) sentry_sdk.flush() mcp_spans = [ item.payload @@ -328,16 +324,15 @@ async def test_tool(tool_name, arguments): return {"result": "success", "value": 42} 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", - ) + 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: @@ -568,16 +563,15 @@ def failing_tool(tool_name, arguments): raise ValueError("Tool execution failed") 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", - ) + result = await stdio( + server, + method="tools/call", + params={ + "name": "bad_tool", + "arguments": {}, + }, + request_id="req-error", + ) sentry_sdk.flush() resp = _get_response(result) @@ -644,16 +638,15 @@ async def test_prompt(name, arguments): return prompt_result 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", - ) + 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" @@ -803,16 +796,15 @@ async def failing_prompt(name, arguments): raise RuntimeError("Prompt not found") 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", - ) + 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" @@ -867,15 +859,14 @@ async def test_resource(uri): ] 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", - ) + 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( @@ -1004,15 +995,14 @@ def failing_resource(uri): raise FileNotFoundError("Resource not found") 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", - ) + 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") @@ -1066,16 +1056,15 @@ def test_tool_tuple(tool_name, arguments): return (unstructured, structured) 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", - ) + 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") @@ -1140,16 +1129,15 @@ def test_tool_unstructured(tool_name, arguments): ] 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", - ) + 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") @@ -1319,16 +1307,15 @@ def test_prompt_dict(name, arguments): } 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", - ) + 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") @@ -1378,16 +1365,15 @@ def test_tool_complex(tool_name, arguments): "number": 42, } 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", - ) + 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") @@ -1713,8 +1699,7 @@ async def test_tool(tool_name, arguments): "arguments": {"x": 10, "y": 5}, } 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") + 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 @@ -1825,8 +1810,7 @@ async def test_tool(tool_name, arguments): "arguments": {"x": 10, "y": 5}, } 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") + 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 @@ -1937,10 +1921,7 @@ async def test_prompt(name, arguments): "arguments": {"language": "python"}, } 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" - ) + 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 @@ -1999,8 +1980,7 @@ async def test_tool(tool_name, arguments): params = {"name": "calculate", "arguments": {"x": 10}} 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") + 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 From d812b5bf389851e198e6a51d9b56b6939a2aa27a Mon Sep 17 00:00:00 2001 From: Alexander Alderman Webb Date: Wed, 9 Sep 2026 11:06:17 +0200 Subject: [PATCH 09/10] test: Remove test_fastmcp_mixed_sync_async_tools --- tests/integrations/fastmcp/test_fastmcp.py | 57 ---------------------- 1 file changed, 57 deletions(-) diff --git a/tests/integrations/fastmcp/test_fastmcp.py b/tests/integrations/fastmcp/test_fastmcp.py index dfcd3bc01d..e5486083d7 100644 --- a/tests/integrations/fastmcp/test_fastmcp.py +++ b/tests/integrations/fastmcp/test_fastmcp.py @@ -1073,60 +1073,3 @@ def stdio_tool(n: int) -> dict: # Check that stdio transport is detected assert span["attributes"].get(SPANDATA.MCP_TRANSPORT) == "stdio" - - -@pytest.mark.asyncio -@pytest.mark.parametrize("FastMCP", fastmcp_implementations, ids=fastmcp_ids) -async def test_fastmcp_mixed_sync_async_tools( - sentry_init, - capture_items, - FastMCP, - stdio, -): - """Test mixing sync and async tools in FastMCP""" - sentry_init( - integrations=[MCPIntegration()], - traces_sample_rate=1.0, - trace_lifecycle="stream", - ) - - 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 - - items = capture_items("span") - # 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" From b4b9d89a172fea7c2a0e7a04bbc378cef2a4482c Mon Sep 17 00:00:00 2001 From: Alexander Alderman Webb Date: Wed, 9 Sep 2026 11:11:44 +0200 Subject: [PATCH 10/10] test: Remove test_fastmcp_multiple_tools --- tests/integrations/fastmcp/test_fastmcp.py | 85 ---------------------- 1 file changed, 85 deletions(-) diff --git a/tests/integrations/fastmcp/test_fastmcp.py b/tests/integrations/fastmcp/test_fastmcp.py index e5486083d7..6d59f2353c 100644 --- a/tests/integrations/fastmcp/test_fastmcp.py +++ b/tests/integrations/fastmcp/test_fastmcp.py @@ -476,91 +476,6 @@ def failing_tool(value: int) -> int: assert error_event["exception"]["values"][0]["value"] == "Tool execution failed" -@pytest.mark.asyncio -@pytest.mark.parametrize("FastMCP", fastmcp_implementations, ids=fastmcp_ids) -async def test_fastmcp_multiple_tools( - sentry_init, - capture_items, - FastMCP, - stdio, -): - """Test that multiple FastMCP tool calls create multiple spans""" - sentry_init( - integrations=[MCPIntegration()], - traces_sample_rate=1.0, - trace_lifecycle="stream", - ) - - 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 - - @mcp.tool() - def tool_three(z: int) -> int: - """Third tool""" - return z - 5 - - items = capture_items("span") - 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" - - @pytest.mark.asyncio @pytest.mark.parametrize("FastMCP", fastmcp_implementations, ids=fastmcp_ids) async def test_fastmcp_tool_with_complex_return(