From aeb035ba818c34ac30a459c7a03cd8814ff011d1 Mon Sep 17 00:00:00 2001 From: Alexander Alderman Webb Date: Tue, 29 Sep 2026 11:29:07 +0200 Subject: [PATCH 1/3] ref(mcp): Drop send_default_pii support --- sentry_sdk/integrations/mcp.py | 45 +- tests/integrations/fastmcp/test_fastmcp.py | 267 +++++- tests/integrations/mcp/test_mcp.py | 920 ++++++++++++--------- 3 files changed, 764 insertions(+), 468 deletions(-) diff --git a/sentry_sdk/integrations/mcp.py b/sentry_sdk/integrations/mcp.py index 66a1df722a..798b1da972 100644 --- a/sentry_sdk/integrations/mcp.py +++ b/sentry_sdk/integrations/mcp.py @@ -15,11 +15,9 @@ import sentry_sdk 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.utils import ( capture_internal_exceptions, event_from_exception, - has_data_collection_enabled, package_version, safe_serialize, ) @@ -245,15 +243,9 @@ async def _instrument_tool_call( client = sentry_sdk.get_client() handler_name = ctx.params["name"] arguments = ctx.params.get("arguments") - if arguments is None: + if arguments is None or not client.options["data_collection"]["gen_ai"]["inputs"]: arguments = {} - if has_data_collection_enabled(client.options): - if not client.options["data_collection"]["gen_ai"]["inputs"]: - # Arguments can contain sensitive data and shouldn't be added to the span - # if the user has opted out via this config. - arguments = {} - # Get request ID, session ID, and transport from context request_id, session_id, mcp_transport = _get_request_context_data(ctx=ctx) @@ -292,21 +284,16 @@ async def _instrument_tool_call( 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(): - 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"]) - if result_content is not None and should_include_result_data: + if ( + result_content is not None + and client.options["data_collection"]["gen_ai"]["outputs"] + ): span.set_attribute( SPANDATA.MCP_TOOL_RESULT_CONTENT, safe_serialize(result_content), @@ -337,15 +324,9 @@ async def _instrument_prompt_get( arguments = ctx.params.get("arguments") - if arguments is None: + if arguments is None or not client.options["data_collection"]["gen_ai"]["inputs"]: arguments = {} - if has_data_collection_enabled(client.options): - if not client.options["data_collection"]["gen_ai"]["inputs"]: - # Arguments can contain sensitive data and shouldn't be added to the span - # if the user has opted out via this config. - arguments = {} - # Get request ID, session ID, and transport from context request_id, session_id, mcp_transport = _get_request_context_data(ctx=ctx) @@ -384,14 +365,6 @@ async def _instrument_prompt_get( 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(): - 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 @@ -408,7 +381,11 @@ async def _instrument_prompt_get( ) # Only set role and content for single-message prompts if PII is allowed - if message_count == 1 and should_include_result_data and messages: + if ( + message_count == 1 + and client.options["data_collection"]["gen_ai"]["inputs"] + and messages + ): first_message = messages[0] # Extract role role = None diff --git a/tests/integrations/fastmcp/test_fastmcp.py b/tests/integrations/fastmcp/test_fastmcp.py index 10699de562..e16b6a64d9 100644 --- a/tests/integrations/fastmcp/test_fastmcp.py +++ b/tests/integrations/fastmcp/test_fastmcp.py @@ -266,22 +266,79 @@ def reset_request_ctx(): @pytest.mark.asyncio @pytest.mark.parametrize("FastMCP", fastmcp_implementations, ids=fastmcp_ids) -@pytest.mark.parametrize( - "send_default_pii", - [True, False], -) async def test_fastmcp_tool_sync( sentry_init, capture_items, FastMCP, - send_default_pii, stdio, ): """Test that FastMCP synchronous tool handlers create proper spans""" sentry_init( integrations=[MCPIntegration()], traces_sample_rate=1.0, - send_default_pii=send_default_pii, + data_collection={ + "gen_ai": { + "inputs": True, + "outputs": True, + } + }, + ) + + mcp = FastMCP("Test Server") + + @mcp.tool() + def add_numbers(a: int, b: int) -> dict: + """Add two numbers together""" + return {"result": a + b, "operation": "addition"} + + items = capture_items("span") + + # Call through MCP protocol to trigger instrumentation + await stdio( + mcp._mcp_server, + method="tools/call", + params={ + "name": "add_numbers", + "arguments": {"a": 10, "b": 5}, + }, + request_id="req-123", + ) + + sentry_sdk.flush() + spans = [item.payload for item in items] + assert len(spans) == 1 + + # Verify span structure + span = spans[0] + assert span["attributes"]["sentry.op"] == OP.MCP_SERVER + assert span["attributes"]["sentry.origin"] == "auto.ai.mcp" + assert span["name"] == "tools/call add_numbers" + assert span["attributes"][SPANDATA.MCP_TOOL_NAME] == "add_numbers" + assert span["attributes"][SPANDATA.MCP_METHOD_NAME] == "tools/call" + assert span["attributes"][SPANDATA.MCP_TRANSPORT] == "stdio" + assert span["attributes"][SPANDATA.MCP_REQUEST_ID] == "req-123" + + assert SPANDATA.MCP_TOOL_RESULT_CONTENT in span["attributes"] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("FastMCP", fastmcp_implementations, ids=fastmcp_ids) +async def test_fastmcp_tool_sync_no_sensitive_data( + sentry_init, + capture_items, + FastMCP, + stdio, +): + """Test that FastMCP synchronous tool handlers create proper spans""" + sentry_init( + integrations=[MCPIntegration()], + traces_sample_rate=1.0, + data_collection={ + "gen_ai": { + "inputs": False, + "outputs": False, + } + }, ) mcp = FastMCP("Test Server") @@ -318,27 +375,97 @@ def add_numbers(a: int, b: int) -> dict: assert span["attributes"][SPANDATA.MCP_TRANSPORT] == "stdio" assert span["attributes"][SPANDATA.MCP_REQUEST_ID] == "req-123" - # Check PII-sensitive data - if send_default_pii: - assert SPANDATA.MCP_TOOL_RESULT_CONTENT in span["attributes"] - else: - assert SPANDATA.MCP_TOOL_RESULT_CONTENT not in span["attributes"] + assert SPANDATA.MCP_TOOL_RESULT_CONTENT not in span["attributes"] @pytest.mark.parametrize("FastMCP", fastmcp_implementations, ids=fastmcp_ids) @pytest.mark.asyncio -@pytest.mark.parametrize( - "send_default_pii", - [True, False], -) -async def test_fastmcp_tool_async( - sentry_init, capture_items, FastMCP, send_default_pii, json_rpc +async def test_fastmcp_tool_async(sentry_init, capture_items, FastMCP, json_rpc): + """Test that FastMCP async tool handlers create proper spans""" + sentry_init( + integrations=[MCPIntegration()], + traces_sample_rate=1.0, + data_collection={ + "gen_ai": { + "inputs": True, + "outputs": True, + } + }, + ) + + 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(), + ) + + @mcp.tool() + async def multiply_numbers(x: int, y: int) -> dict: + """Multiply two numbers together""" + return {"result": x * y, "operation": "multiplication"} + + 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] + + 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 + + assert SPANDATA.MCP_TOOL_RESULT_CONTENT in span["attributes"] + + +@pytest.mark.parametrize("FastMCP", fastmcp_implementations, ids=fastmcp_ids) +@pytest.mark.asyncio +async def test_fastmcp_tool_async_no_sensitive_data( + sentry_init, capture_items, FastMCP, json_rpc ): """Test that FastMCP async tool handlers create proper spans""" sentry_init( integrations=[MCPIntegration()], traces_sample_rate=1.0, - send_default_pii=send_default_pii, + data_collection={ + "gen_ai": { + "inputs": False, + "outputs": False, + } + }, ) mcp = FastMCP("Test Server") @@ -396,11 +523,7 @@ async def multiply_numbers(x: int, y: int) -> dict: 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: - assert SPANDATA.MCP_TOOL_RESULT_CONTENT in span["attributes"] - else: - assert SPANDATA.MCP_TOOL_RESULT_CONTENT not in span["attributes"] + assert SPANDATA.MCP_TOOL_RESULT_CONTENT not in span["attributes"] @pytest.mark.asyncio @@ -417,6 +540,7 @@ async def test_fastmcp_tool_with_error( sentry_init( integrations=[MCPIntegration(), LoggingIntegration(event_level=logging.ERROR)], traces_sample_rate=1.0, + data_collection={}, ) mcp = FastMCP("Test Server") @@ -465,7 +589,12 @@ async def test_fastmcp_tool_with_complex_return( sentry_init( integrations=[MCPIntegration()], traces_sample_rate=1.0, - send_default_pii=True, + data_collection={ + "gen_ai": { + "inputs": True, + "outputs": True, + } + }, ) mcp = FastMCP("Test Server") @@ -508,22 +637,22 @@ def get_user_data(user_id: int) -> dict: @pytest.mark.asyncio @pytest.mark.parametrize("FastMCP", fastmcp_implementations, ids=fastmcp_ids) -@pytest.mark.parametrize( - "send_default_pii", - [True, False], -) async def test_fastmcp_prompt_sync( sentry_init, capture_items, FastMCP, - send_default_pii, stdio, ): """Test that FastMCP synchronous prompt handlers create proper spans""" sentry_init( integrations=[MCPIntegration()], traces_sample_rate=1.0, - send_default_pii=send_default_pii, + data_collection={ + "gen_ai": { + "inputs": True, + "outputs": True, + } + }, ) mcp = FastMCP("Test Server") @@ -570,11 +699,74 @@ def code_help_prompt(language: str): 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: - assert SPANDATA.MCP_PROMPT_RESULT_MESSAGE_CONTENT in span["attributes"] - else: - assert SPANDATA.MCP_PROMPT_RESULT_MESSAGE_CONTENT not in span["attributes"] + assert SPANDATA.MCP_PROMPT_RESULT_MESSAGE_CONTENT in span["attributes"] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("FastMCP", fastmcp_implementations, ids=fastmcp_ids) +async def test_fastmcp_prompt_sync_no_sensitive_data( + sentry_init, + capture_items, + FastMCP, + stdio, +): + """Test that FastMCP synchronous prompt handlers create proper spans""" + sentry_init( + integrations=[MCPIntegration()], + traces_sample_rate=1.0, + data_collection={ + "gen_ai": { + "inputs": False, + "outputs": False, + } + }, + ) + + mcp = FastMCP("Test Server") + + # Try to register a prompt handler (may not be supported in all versions) + if hasattr(mcp, "prompt"): + + @mcp.prompt() + def code_help_prompt(language: str): + """Get help for a programming language""" + message = Message( + { + "role": "user", + "content": { + "type": "text", + "text": f"Tell me about {language}", + }, + } + ) + + return [message] + + items = capture_items("span") + with sentry_sdk.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" + + assert SPANDATA.MCP_PROMPT_RESULT_MESSAGE_CONTENT not in span["attributes"] # ============================================================================= @@ -594,6 +786,7 @@ async def test_fastmcp_resource_sync( sentry_init( integrations=[MCPIntegration()], traces_sample_rate=1.0, + data_collection={}, ) mcp = FastMCP("Test Server") @@ -642,6 +835,7 @@ async def test_fastmcp_resource_async(sentry_init, capture_items, FastMCP, json_ sentry_init( integrations=[MCPIntegration()], traces_sample_rate=1.0, + data_collection={}, ) mcp = FastMCP("Test Server") @@ -715,6 +909,7 @@ async def test_fastmcp_span_origin( sentry_init( integrations=[MCPIntegration()], traces_sample_rate=1.0, + data_collection={}, ) mcp = FastMCP("Test Server") @@ -756,6 +951,7 @@ def test_fastmcp_http_transport(sentry_init, capture_items, FastMCP, json_rpc): sentry_init( integrations=[MCPIntegration()], traces_sample_rate=1.0, + data_collection={}, ) mcp = FastMCP("Test Server") @@ -819,6 +1015,7 @@ async def test_fastmcp_stdio_transport( sentry_init( integrations=[MCPIntegration()], traces_sample_rate=1.0, + data_collection={}, ) mcp = FastMCP("Test Server") diff --git a/tests/integrations/mcp/test_mcp.py b/tests/integrations/mcp/test_mcp.py index 982d25c71f..6f1d9751fd 100644 --- a/tests/integrations/mcp/test_mcp.py +++ b/tests/integrations/mcp/test_mcp.py @@ -142,6 +142,7 @@ async def test_tool_handler_constructor_registration(sentry_init, capture_items, sentry_init( integrations=[MCPIntegration()], traces_sample_rate=1.0, + data_collection={}, ) async def test_tool(ctx, params): @@ -171,7 +172,6 @@ async def test_tool(ctx, params): assert data[SPANDATA.MCP_TOOL_NAME] == "calculate" assert data[SPANDATA.MCP_METHOD_NAME] == "tools/call" assert data[SPANDATA.MCP_REQUEST_ID] == "req-ctor" - assert data["mcp.request.argument.x"] == "10" @pytest.mark.asyncio @@ -185,6 +185,7 @@ async def test_mcpserver_high_level_tool_instrumented( sentry_init( integrations=[MCPIntegration()], traces_sample_rate=1.0, + data_collection={}, ) mcp_server = MCPServer("test-server") @@ -216,6 +217,7 @@ async def test_wrapping_handler_is_idempotent(sentry_init, capture_items, stdio) sentry_init( integrations=[MCPIntegration()], traces_sample_rate=1.0, + data_collection={}, ) async def test_tool(ctx, params): @@ -246,21 +248,21 @@ async def test_tool(ctx, params): @pytest.mark.asyncio -@pytest.mark.parametrize( - "send_default_pii", - [True, False], -) async def test_tool_handler_stdio( sentry_init, capture_items, - send_default_pii, stdio, ): """Test that synchronous tool handlers create proper spans""" sentry_init( integrations=[MCPIntegration()], traces_sample_rate=1.0, - send_default_pii=send_default_pii, + data_collection={ + "gen_ai": { + "inputs": True, + "outputs": True, + } + }, ) server = Server("test-server") @@ -312,33 +314,96 @@ async def test_tool(ctx, params): assert data["mcp.request.argument.x"] == "10" assert data["mcp.request.argument.y"] == "5" - # Check PII-sensitive data is only present when both flags are True - if send_default_pii: - assert data[SPANDATA.MCP_TOOL_RESULT_CONTENT] == json.dumps( - { - "result": "success", - "value": 42, + assert data[SPANDATA.MCP_TOOL_RESULT_CONTENT] == json.dumps( + { + "result": "success", + "value": 42, + } + ) + assert data[SPANDATA.MCP_TOOL_RESULT_CONTENT_COUNT] == 2 + + +@pytest.mark.asyncio +async def test_tool_handler_stdio_no_sensitive_data( + sentry_init, + capture_items, + stdio, +): + """Test that synchronous tool handlers create proper spans""" + sentry_init( + integrations=[MCPIntegration()], + traces_sample_rate=1.0, + data_collection={ + "gen_ai": { + "inputs": False, + "outputs": False, } + }, + ) + + server = Server("test-server") + + async def test_tool(ctx, params): + return CallToolResult( + content=[ + TextContent( + type="text", + text=json.dumps({"result": "success", "value": 42}), + ) + ], + structured_content={"result": "success", "value": 42}, ) - assert data[SPANDATA.MCP_TOOL_RESULT_CONTENT_COUNT] == 2 - else: - assert SPANDATA.MCP_TOOL_RESULT_CONTENT not in data - assert SPANDATA.MCP_TOOL_RESULT_CONTENT_COUNT not in data + + server.add_request_handler("tools/call", CallToolRequestParams, test_tool) + + items = capture_items("span") + result = await stdio( + server, + method="tools/call", + params={ + "name": "calculate", + "arguments": {"x": 10, "y": 5}, + }, + request_id="req-123", + ) + sentry_sdk.flush() + + assert _get_response(result).result["structuredContent"] == { + "result": "success", + "value": 42, + } + + 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" + assert data[SPANDATA.MCP_METHOD_NAME] == "tools/call" + assert data[SPANDATA.MCP_TRANSPORT] == "stdio" + assert data[SPANDATA.NETWORK_TRANSPORT] == "pipe" + assert data[SPANDATA.MCP_REQUEST_ID] == "req-123" + assert SPANDATA.MCP_SESSION_ID not in data + + assert SPANDATA.MCP_TOOL_RESULT_CONTENT not in data + assert SPANDATA.MCP_TOOL_RESULT_CONTENT_COUNT not in data @pytest.mark.asyncio -@pytest.mark.parametrize( - "send_default_pii", - [True, False], -) -async def test_tool_handler_streamable_http( - sentry_init, capture_items, send_default_pii, json_rpc -): +async def test_tool_handler_streamable_http(sentry_init, capture_items, json_rpc): """Test that async tool handlers create proper spans""" sentry_init( integrations=[MCPIntegration()], traces_sample_rate=1.0, - send_default_pii=send_default_pii, + data_collection={ + "gen_ai": { + "inputs": True, + "outputs": True, + } + }, ) server = Server("test-server") @@ -399,13 +464,83 @@ async def test_tool_async(ctx, params): assert data[SPANDATA.MCP_SESSION_ID] == session_id assert data["mcp.request.argument.data"] == "test" - # Check PII-sensitive data - if send_default_pii: - assert data[SPANDATA.MCP_TOOL_RESULT_CONTENT] == json.dumps( - {"status": "completed"} + assert data[SPANDATA.MCP_TOOL_RESULT_CONTENT] == json.dumps({"status": "completed"}) + + +@pytest.mark.asyncio +async def test_tool_handler_streamable_http_no_sensitive_data( + sentry_init, capture_items, json_rpc +): + """Test that async tool handlers create proper spans""" + sentry_init( + integrations=[MCPIntegration()], + traces_sample_rate=1.0, + data_collection={ + "gen_ai": { + "inputs": False, + "outputs": False, + } + }, + ) + + server = Server("test-server") + + async def test_tool_async(ctx, params): + return CallToolResult( + content=[ + TextContent( + type="text", + text=json.dumps({"status": "completed"}), + ) + ] ) - else: - assert SPANDATA.MCP_TOOL_RESULT_CONTENT not in data + + server.add_request_handler("tools/call", CallToolRequestParams, test_tool_async) + + session_manager = StreamableHTTPSessionManager( + app=server, + json_response=True, + ) + + app = Starlette( + routes=[ + Mount("/mcp", app=session_manager.handle_request), + ], + lifespan=lambda app: session_manager.run(), + ) + 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() + + assert result.json()["result"]["content"][0]["text"] == json.dumps( + {"status": "completed"} + ) + 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" + assert data[SPANDATA.MCP_METHOD_NAME] == "tools/call" + assert data[SPANDATA.MCP_TRANSPORT] == "http" + assert data[SPANDATA.NETWORK_TRANSPORT] == "tcp" + assert data[SPANDATA.MCP_REQUEST_ID] == "req-456" + assert data[SPANDATA.MCP_SESSION_ID] == session_id + + assert SPANDATA.MCP_TOOL_RESULT_CONTENT not in data @pytest.mark.asyncio @@ -421,6 +556,7 @@ async def test_tool_handler_stateless_streamable_http( sentry_init( integrations=[MCPIntegration()], traces_sample_rate=1.0, + data_collection={}, ) server = Server("test-server") @@ -468,6 +604,7 @@ async def test_tool_handler_with_error(sentry_init, capture_items, stdio): sentry_init( integrations=[MCPIntegration()], traces_sample_rate=1.0, + data_collection={}, ) server = Server("test-server") @@ -507,21 +644,21 @@ async def failing_tool(ctx, params): @pytest.mark.asyncio -@pytest.mark.parametrize( - "send_default_pii", - [True, False], -) async def test_prompt_handler_stdio( sentry_init, capture_items, - send_default_pii, stdio, ): """Test that synchronous prompt handlers create proper spans""" sentry_init( integrations=[MCPIntegration()], traces_sample_rate=1.0, - send_default_pii=send_default_pii, + data_collection={ + "gen_ai": { + "inputs": True, + "outputs": True, + } + }, ) server = Server("test-server") @@ -575,30 +712,168 @@ async def test_prompt(ctx, params): # Message count is always captured assert data[SPANDATA.MCP_PROMPT_RESULT_MESSAGE_COUNT] == 1 - # For single message prompts, role and content should be captured only with PII - if send_default_pii: - assert data[SPANDATA.MCP_PROMPT_RESULT_MESSAGE_ROLE] == "user" - assert ( - data[SPANDATA.MCP_PROMPT_RESULT_MESSAGE_CONTENT] == "Tell me about Python" - ) - else: - assert SPANDATA.MCP_PROMPT_RESULT_MESSAGE_ROLE not in data - assert SPANDATA.MCP_PROMPT_RESULT_MESSAGE_CONTENT not in data + assert data[SPANDATA.MCP_PROMPT_RESULT_MESSAGE_ROLE] == "user" + assert data[SPANDATA.MCP_PROMPT_RESULT_MESSAGE_CONTENT] == "Tell me about Python" @pytest.mark.asyncio -@pytest.mark.parametrize( - "send_default_pii", - [True, False], -) -async def test_prompt_handler_streamable_http( - sentry_init, capture_items, send_default_pii, json_rpc +async def test_prompt_handler_stdio_no_sensitive_data( + sentry_init, + capture_items, + stdio, +): + """Test that synchronous prompt handlers create proper spans""" + sentry_init( + integrations=[MCPIntegration()], + traces_sample_rate=1.0, + data_collection={ + "gen_ai": { + "inputs": False, + "outputs": False, + } + }, + ) + + server = Server("test-server") + + prompt_result = GetPromptResult( + description="A helpful test prompt", + messages=[ + PromptMessage( + role="user", + content=TextContent(type="text", text="Tell me about Python"), + ), + ], + ) + + async def test_prompt(ctx, params): + return prompt_result + + server.add_request_handler("prompts/get", GetPromptRequestParams, test_prompt) + + items = capture_items("span") + result = await stdio( + server, + method="prompts/get", + params={ + "name": "code_help", + "arguments": {"language": "python"}, + }, + request_id="req-prompt", + ) + sentry_sdk.flush() + + assert _get_response(result).result["messages"][0]["role"] == "user" + assert ( + _get_response(result).result["messages"][0]["content"]["text"] + == "Tell me about Python" + ) + 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" + assert data[SPANDATA.MCP_METHOD_NAME] == "prompts/get" + assert data[SPANDATA.MCP_TRANSPORT] == "stdio" + assert data[SPANDATA.MCP_REQUEST_ID] == "req-prompt" + + # Message count is always captured + assert data[SPANDATA.MCP_PROMPT_RESULT_MESSAGE_COUNT] == 1 + + assert SPANDATA.MCP_PROMPT_RESULT_MESSAGE_ROLE not in data + assert SPANDATA.MCP_PROMPT_RESULT_MESSAGE_CONTENT not in data + + +@pytest.mark.asyncio +async def test_prompt_handler_streamable_http(sentry_init, capture_items, json_rpc): + """Test that async prompt handlers create proper spans""" + sentry_init( + integrations=[MCPIntegration()], + traces_sample_rate=1.0, + data_collection={ + "gen_ai": { + "inputs": True, + "outputs": True, + } + }, + ) + + server = Server("test-server") + + prompt_result = GetPromptResult( + description="A helpful test prompt", + messages=[ + PromptMessage( + role="user", + content=TextContent(type="text", text="You are a helpful assistant"), + ), + PromptMessage( + role="user", content=TextContent(type="text", text="What is MCP?") + ), + ], + ) + + async def test_prompt_async(ctx, params): + return prompt_result + + server.add_request_handler("prompts/get", GetPromptRequestParams, test_prompt_async) + + session_manager = StreamableHTTPSessionManager( + app=server, + json_response=True, + ) + + app = Starlette( + routes=[ + Mount("/mcp", app=session_manager.handle_request), + ], + lifespan=lambda app: session_manager.run(), + ) + 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 + 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 + # Role/content are never captured for multi-message prompts (even with PII) + assert SPANDATA.MCP_PROMPT_RESULT_MESSAGE_ROLE not in data + assert SPANDATA.MCP_PROMPT_RESULT_MESSAGE_CONTENT not in data + + +@pytest.mark.asyncio +async def test_prompt_handler_streamable_http_no_sensitive_data( + sentry_init, capture_items, json_rpc ): """Test that async prompt handlers create proper spans""" sentry_init( integrations=[MCPIntegration()], traces_sample_rate=1.0, - send_default_pii=send_default_pii, + data_collection={ + "gen_ai": { + "inputs": False, + "outputs": False, + } + }, ) server = Server("test-server") @@ -665,6 +940,7 @@ async def test_prompt_handler_with_error(sentry_init, capture_items, stdio): sentry_init( integrations=[MCPIntegration()], traces_sample_rate=1.0, + data_collection={}, ) server = Server("test-server") @@ -705,6 +981,7 @@ async def test_resource_handler_stdio(sentry_init, capture_items, stdio): sentry_init( integrations=[MCPIntegration()], traces_sample_rate=1.0, + data_collection={}, ) server = Server("test-server") @@ -761,6 +1038,7 @@ async def test_resource_handler_streamable_http(sentry_init, capture_items, json sentry_init( integrations=[MCPIntegration()], traces_sample_rate=1.0, + data_collection={}, ) server = Server("test-server") @@ -823,6 +1101,7 @@ async def test_resource_handler_with_error(sentry_init, capture_items, stdio): sentry_init( integrations=[MCPIntegration()], traces_sample_rate=1.0, + data_collection={}, ) server = Server("test-server") @@ -857,21 +1136,21 @@ async def failing_resource(ctx, params): @pytest.mark.asyncio -@pytest.mark.parametrize( - "send_default_pii", - [True, False], -) async def test_tool_result_extraction_tuple( sentry_init, capture_items, - send_default_pii, stdio, ): """Test extraction of tool results from tuple format (UnstructuredContent, StructuredContent)""" sentry_init( integrations=[MCPIntegration()], traces_sample_rate=1.0, - send_default_pii=send_default_pii, + data_collection={ + "gen_ai": { + "inputs": True, + "outputs": True, + } + }, ) server = Server("test-server") @@ -900,35 +1179,130 @@ async def test_tool_tuple(ctx, params): assert span is not None data = span["attributes"] - if send_default_pii: - assert data[SPANDATA.MCP_TOOL_RESULT_CONTENT] == json.dumps( - { - "key": "value", - "count": 5, + assert data[SPANDATA.MCP_TOOL_RESULT_CONTENT] == json.dumps( + { + "key": "value", + "count": 5, + } + ) + assert data[SPANDATA.MCP_TOOL_RESULT_CONTENT_COUNT] == 2 + + +@pytest.mark.asyncio +async def test_tool_result_extraction_tuple_no_sensitive_data( + sentry_init, + capture_items, + stdio, +): + """Test extraction of tool results from tuple format (UnstructuredContent, StructuredContent)""" + sentry_init( + integrations=[MCPIntegration()], + traces_sample_rate=1.0, + data_collection={ + "gen_ai": { + "inputs": False, + "outputs": False, } + }, + ) + + server = Server("test-server") + + async def test_tool_tuple(ctx, params): + return CallToolResult( + content=[TextContent(type="text", text="Result text")], + structured_content={"key": "value", "count": 5}, ) - assert data[SPANDATA.MCP_TOOL_RESULT_CONTENT_COUNT] == 2 - else: - assert SPANDATA.MCP_TOOL_RESULT_CONTENT not in data - assert SPANDATA.MCP_TOOL_RESULT_CONTENT_COUNT not in data + + server.add_request_handler("tools/call", CallToolRequestParams, test_tool_tuple) + + items = capture_items("span") + 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"] + + assert SPANDATA.MCP_TOOL_RESULT_CONTENT not in data + assert SPANDATA.MCP_TOOL_RESULT_CONTENT_COUNT not in data @pytest.mark.asyncio -@pytest.mark.parametrize( - "send_default_pii", - [True, False], -) async def test_tool_result_extraction_unstructured( sentry_init, capture_items, - send_default_pii, stdio, ): """Test extraction of tool results from UnstructuredContent (list of content blocks)""" sentry_init( integrations=[MCPIntegration()], traces_sample_rate=1.0, - send_default_pii=send_default_pii, + data_collection={ + "gen_ai": { + "inputs": True, + "outputs": True, + } + }, + ) + + server = Server("test-server") + + async def test_tool_unstructured(ctx, params): + return CallToolResult( + content=[ + TextContent(type="text", text="First part"), + TextContent(type="text", text="Second part"), + ] + ) + + server.add_request_handler( + "tools/call", CallToolRequestParams, test_tool_unstructured + ) + + items = capture_items("span") + 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"] + + assert data[SPANDATA.MCP_TOOL_RESULT_CONTENT] == "First part Second part" + + +@pytest.mark.asyncio +async def test_tool_result_extraction_unstructured_no_sensitive_data( + sentry_init, + capture_items, + stdio, +): + """Test extraction of tool results from UnstructuredContent (list of content blocks)""" + sentry_init( + integrations=[MCPIntegration()], + traces_sample_rate=1.0, + data_collection={ + "gen_ai": { + "inputs": False, + "outputs": False, + } + }, ) server = Server("test-server") @@ -961,11 +1335,7 @@ async def test_tool_unstructured(ctx, params): assert span is not None data = span["attributes"] - # Should extract and join text from content blocks only with PII - if send_default_pii: - assert data[SPANDATA.MCP_TOOL_RESULT_CONTENT] == "First part Second part" - else: - assert SPANDATA.MCP_TOOL_RESULT_CONTENT not in data + assert SPANDATA.MCP_TOOL_RESULT_CONTENT not in data @pytest.mark.asyncio @@ -974,6 +1344,7 @@ async def test_multiple_handlers(sentry_init, capture_items, stdio): sentry_init( integrations=[MCPIntegration()], traces_sample_rate=1.0, + data_collection={}, ) server = Server("test-server") @@ -1051,21 +1422,76 @@ async def prompt_handler(ctx, params): @pytest.mark.asyncio -@pytest.mark.parametrize( - "send_default_pii", - [True, False], -) async def test_prompt_with_dict_result( sentry_init, capture_items, - send_default_pii, stdio, ): """Test prompt handler with dict result instead of GetPromptResult object""" sentry_init( integrations=[MCPIntegration()], traces_sample_rate=1.0, - send_default_pii=send_default_pii, + data_collection={ + "gen_ai": { + "inputs": True, + "outputs": True, + } + }, + ) + + server = Server("test-server") + + async def test_prompt_dict(ctx, params): + return GetPromptResult( + messages=[ + PromptMessage( + role="user", + content=TextContent(type="text", text="Hello from dict"), + ) + ] + ) + + server.add_request_handler("prompts/get", GetPromptRequestParams, test_prompt_dict) + + items = capture_items("span") + 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"] + + # Message count is always captured + assert data[SPANDATA.MCP_PROMPT_RESULT_MESSAGE_COUNT] == 1 + + assert data[SPANDATA.MCP_PROMPT_RESULT_MESSAGE_ROLE] == "user" + assert data[SPANDATA.MCP_PROMPT_RESULT_MESSAGE_CONTENT] == "Hello from dict" + + +@pytest.mark.asyncio +async def test_prompt_with_dict_result_no_sensitive_data( + sentry_init, + capture_items, + stdio, +): + """Test prompt handler with dict result instead of GetPromptResult object""" + sentry_init( + integrations=[MCPIntegration()], + traces_sample_rate=1.0, + data_collection={ + "gen_ai": { + "inputs": False, + "outputs": False, + } + }, ) server = Server("test-server") @@ -1101,13 +1527,8 @@ async def test_prompt_dict(ctx, params): # Message count is always captured assert data[SPANDATA.MCP_PROMPT_RESULT_MESSAGE_COUNT] == 1 - # Role and content only captured with PII - if send_default_pii: - assert data[SPANDATA.MCP_PROMPT_RESULT_MESSAGE_ROLE] == "user" - assert data[SPANDATA.MCP_PROMPT_RESULT_MESSAGE_CONTENT] == "Hello from dict" - else: - assert SPANDATA.MCP_PROMPT_RESULT_MESSAGE_ROLE not in data - assert SPANDATA.MCP_PROMPT_RESULT_MESSAGE_CONTENT not in data + assert SPANDATA.MCP_PROMPT_RESULT_MESSAGE_ROLE not in data + assert SPANDATA.MCP_PROMPT_RESULT_MESSAGE_CONTENT not in data @pytest.mark.asyncio @@ -1116,6 +1537,12 @@ async def test_tool_with_complex_arguments(sentry_init, capture_items, stdio): sentry_init( integrations=[MCPIntegration()], traces_sample_rate=1.0, + data_collection={ + "gen_ai": { + "inputs": True, + "outputs": True, + }, + }, ) server = Server("test-server") @@ -1169,6 +1596,7 @@ async def test_sse_transport_detection_v2(sentry_init, capture_items, json_rpc_s sentry_init( integrations=[MCPIntegration()], traces_sample_rate=1.0, + data_collection={}, ) server = Server("test-server") @@ -1246,6 +1674,7 @@ async def test_streamable_http_scope_propagation(sentry_init, capture_items, jso sentry_init( integrations=[MCPIntegration()], traces_sample_rate=1.0, + data_collection={}, ) server = Server("test-server") @@ -1288,310 +1717,3 @@ async def failing_tool(ctx, params): # The captured error shares the trace of the MCP span, proving the # handler executed under the propagated request scope. assert error_event["contexts"]["trace"]["trace_id"] == span["trace_id"] - - -@pytest.mark.asyncio -@pytest.mark.parametrize( - "data_collection, send_default_pii, expect_input", - [ - pytest.param( - {"gen_ai": {"inputs": True}}, - False, - True, - id="gen-ai-inputs-enabled-overrides-pii-disabled", - ), - pytest.param( - {"gen_ai": {"inputs": False}}, - True, - False, - id="gen-ai-inputs-disabled-overrides-pii-enabled", - ), - pytest.param( - {}, - False, - True, - id="gen-ai-omitted-defaults-to-enabled", - ), - pytest.param( - {"gen_ai": {"outputs": False}}, - False, - True, - id="gen-ai-outputs-disabled-does-not-affect-inputs", - ), - pytest.param( - None, - False, - False, - id="no-data-collection-falls-back-to-send-default-pii", - ), - pytest.param( - None, - True, - True, - id="no-data-collection-pii-enabled-collects", - ), - ], -) -async def test_tool_data_collection_inputs( - sentry_init, - capture_items, - data_collection, - send_default_pii, - expect_input, - stdio, -): - init_kwargs = { - "integrations": [MCPIntegration()], - "traces_sample_rate": 1.0, - "send_default_pii": send_default_pii, - } - if data_collection is not None: - init_kwargs["data_collection"] = data_collection - - sentry_init(**init_kwargs) - - server = Server("test-server") - - async def test_tool(ctx, params): - return CallToolResult( - content=[TextContent(type="text", text="ok")], - structured_content={"result": "success"}, - ) - - server.add_request_handler("tools/call", CallToolRequestParams, test_tool) - - params = { - "name": "calculate", - "arguments": {"x": 10, "y": 5}, - } - items = capture_items("span") - await stdio(server, method="tools/call", params=params, request_id="req-1") - sentry_sdk.flush() - span = _find_mcp_span(items, method_name="tools/call") - assert span is not None - data = span["attributes"] - - # Arguments are only gated once data_collection is configured; without it they - # are set unconditionally, as they were before data_collection existed. - if expect_input or data_collection is None: - assert data["mcp.request.argument.x"] == "10" - assert data["mcp.request.argument.y"] == "5" - else: - assert "mcp.request.argument.x" not in data - assert "mcp.request.argument.y" not in data - - # Non-sensitive identifying attributes are never gated - assert data[SPANDATA.MCP_TOOL_NAME] == "calculate" - assert data[SPANDATA.MCP_METHOD_NAME] == "tools/call" - assert data[SPANDATA.MCP_TRANSPORT] == "stdio" - assert data[SPANDATA.MCP_REQUEST_ID] == "req-1" - - -@pytest.mark.asyncio -@pytest.mark.parametrize( - "data_collection, send_default_pii, expect_output", - [ - pytest.param( - {"gen_ai": {"outputs": True}}, - False, - True, - id="gen-ai-outputs-enabled-overrides-pii-disabled", - ), - pytest.param( - {"gen_ai": {"outputs": False}}, - True, - False, - id="gen-ai-outputs-disabled-overrides-pii-enabled", - ), - pytest.param( - {}, - False, - True, - id="gen-ai-omitted-defaults-to-enabled", - ), - pytest.param( - {"gen_ai": {"inputs": False}}, - False, - True, - id="gen-ai-inputs-disabled-does-not-affect-outputs", - ), - pytest.param( - None, - False, - False, - id="no-data-collection-falls-back-to-send-default-pii", - ), - pytest.param( - None, - True, - True, - id="no-data-collection-pii-enabled-collects", - ), - ], -) -async def test_tool_data_collection_outputs( - sentry_init, - capture_items, - data_collection, - send_default_pii, - expect_output, - stdio, -): - """Tool result content is gated on data_collection.gen_ai.outputs""" - init_kwargs = { - "integrations": [MCPIntegration()], - "traces_sample_rate": 1.0, - "send_default_pii": send_default_pii, - } - if data_collection is not None: - init_kwargs["data_collection"] = data_collection - - sentry_init(**init_kwargs) - - server = Server("test-server") - - async def test_tool(ctx, params): - return CallToolResult( - content=[ - TextContent( - type="text", - text=json.dumps({"result": "success", "value": 42}), - ) - ], - structured_content={"result": "success", "value": 42}, - ) - - server.add_request_handler("tools/call", CallToolRequestParams, test_tool) - - params = { - "name": "calculate", - "arguments": {"x": 10, "y": 5}, - } - items = capture_items("span") - await stdio(server, method="tools/call", params=params, request_id="req-1") - sentry_sdk.flush() - span = _find_mcp_span(items, method_name="tools/call") - assert span is not None - data = span["attributes"] - - if expect_output: - assert data[SPANDATA.MCP_TOOL_RESULT_CONTENT] == json.dumps( - {"result": "success", "value": 42} - ) - assert data[SPANDATA.MCP_TOOL_RESULT_CONTENT_COUNT] == 2 - else: - assert SPANDATA.MCP_TOOL_RESULT_CONTENT not in data - assert SPANDATA.MCP_TOOL_RESULT_CONTENT_COUNT not in data - - -@pytest.mark.asyncio -@pytest.mark.parametrize( - "data_collection, send_default_pii, expect_input", - [ - pytest.param( - {"gen_ai": {"inputs": True}}, - False, - True, - id="gen-ai-inputs-enabled-overrides-pii-disabled", - ), - pytest.param( - {"gen_ai": {"inputs": False}}, - True, - False, - id="gen-ai-inputs-disabled-overrides-pii-enabled", - ), - pytest.param( - {}, - False, - True, - id="gen-ai-omitted-defaults-to-enabled", - ), - pytest.param( - {"gen_ai": {"outputs": False}}, - False, - True, - id="gen-ai-outputs-disabled-does-not-affect-inputs", - ), - pytest.param( - None, - False, - False, - id="no-data-collection-falls-back-to-send-default-pii", - ), - pytest.param( - None, - True, - True, - id="no-data-collection-pii-enabled-collects", - ), - ], -) -async def test_prompt_data_collection_inputs( - sentry_init, - capture_items, - data_collection, - send_default_pii, - expect_input, - stdio, -): - """Prompt arguments and message content are gated on data_collection.gen_ai.inputs. - - The prompt message content is the prompt fed to a model, so it is an input - even though it arrives on the handler's result. - """ - init_kwargs = { - "integrations": [MCPIntegration()], - "traces_sample_rate": 1.0, - "send_default_pii": send_default_pii, - } - if data_collection is not None: - init_kwargs["data_collection"] = data_collection - - sentry_init(**init_kwargs) - - server = Server("test-server") - - prompt_result = GetPromptResult( - description="A helpful test prompt", - messages=[ - PromptMessage( - role="user", - content=TextContent(type="text", text="Tell me about Python"), - ), - ], - ) - - async def test_prompt(ctx, params): - return prompt_result - - server.add_request_handler("prompts/get", GetPromptRequestParams, test_prompt) - - params = { - "name": "code_help", - "arguments": {"language": "python"}, - } - items = capture_items("span") - await stdio(server, method="prompts/get", params=params, request_id="req-prompt") - sentry_sdk.flush() - span = _find_mcp_span(items, method_name="prompts/get") - assert span is not None - data = span["attributes"] - - # Arguments are only gated once data_collection is configured; without it they - # are set unconditionally, as they were before data_collection existed. - if expect_input or data_collection is None: - assert data["mcp.request.argument.language"] == "python" - else: - assert "mcp.request.argument.language" not in data - - if expect_input: - assert data[SPANDATA.MCP_PROMPT_RESULT_MESSAGE_ROLE] == "user" - assert ( - data[SPANDATA.MCP_PROMPT_RESULT_MESSAGE_CONTENT] == "Tell me about Python" - ) - else: - assert SPANDATA.MCP_PROMPT_RESULT_MESSAGE_ROLE not in data - assert SPANDATA.MCP_PROMPT_RESULT_MESSAGE_CONTENT not in data - - # The message count carries no prompt content, so it is never gated - assert data[SPANDATA.MCP_PROMPT_RESULT_MESSAGE_COUNT] == 1 From d352a66866da5ae61467d3d1590c561f7286cfa6 Mon Sep 17 00:00:00 2001 From: Alexander Alderman Webb Date: Tue, 29 Sep 2026 14:10:51 +0200 Subject: [PATCH 2/3] remove stale comments --- sentry_sdk/integrations/mcp.py | 3 --- tests/integrations/fastmcp/test_fastmcp.py | 1 - tests/integrations/mcp/test_mcp.py | 2 -- 3 files changed, 6 deletions(-) diff --git a/sentry_sdk/integrations/mcp.py b/sentry_sdk/integrations/mcp.py index 798b1da972..499441f8d8 100644 --- a/sentry_sdk/integrations/mcp.py +++ b/sentry_sdk/integrations/mcp.py @@ -279,7 +279,6 @@ async def _instrument_tool_call( if not isinstance(result, dict): return result - # Get integration to check PII settings integration = client.get_integration(MCPIntegration) if integration is None: return result @@ -360,7 +359,6 @@ async def _instrument_prompt_get( if not isinstance(result, dict): return result - # Get integration to check PII settings integration = client.get_integration(MCPIntegration) if integration is None: return result @@ -380,7 +378,6 @@ async def _instrument_prompt_get( SPANDATA.MCP_PROMPT_RESULT_MESSAGE_COUNT, message_count ) - # Only set role and content for single-message prompts if PII is allowed if ( message_count == 1 and client.options["data_collection"]["gen_ai"]["inputs"] diff --git a/tests/integrations/fastmcp/test_fastmcp.py b/tests/integrations/fastmcp/test_fastmcp.py index e16b6a64d9..ac62d94d8e 100644 --- a/tests/integrations/fastmcp/test_fastmcp.py +++ b/tests/integrations/fastmcp/test_fastmcp.py @@ -11,7 +11,6 @@ - Span creation when tools/prompts/resources are called through MCP protocol - Span data accuracy (operation, name, origin, etc.) - Error capture and instrumentation -- PII flag behavior - Request context data extraction - Transport detection (stdio, http, sse) diff --git a/tests/integrations/mcp/test_mcp.py b/tests/integrations/mcp/test_mcp.py index 6f1d9751fd..39dda20602 100644 --- a/tests/integrations/mcp/test_mcp.py +++ b/tests/integrations/mcp/test_mcp.py @@ -855,7 +855,6 @@ async def test_prompt_async(ctx, params): # For multi-message prompts, count is always captured assert data[SPANDATA.MCP_PROMPT_RESULT_MESSAGE_COUNT] == 2 - # Role/content are never captured for multi-message prompts (even with PII) assert SPANDATA.MCP_PROMPT_RESULT_MESSAGE_ROLE not in data assert SPANDATA.MCP_PROMPT_RESULT_MESSAGE_CONTENT not in data @@ -929,7 +928,6 @@ async def test_prompt_async(ctx, params): # For multi-message prompts, count is always captured assert data[SPANDATA.MCP_PROMPT_RESULT_MESSAGE_COUNT] == 2 - # Role/content are never captured for multi-message prompts (even with PII) assert SPANDATA.MCP_PROMPT_RESULT_MESSAGE_ROLE not in data assert SPANDATA.MCP_PROMPT_RESULT_MESSAGE_CONTENT not in data From 15ef777f467b0f0385b0d156ce48e24c05416c6a Mon Sep 17 00:00:00 2001 From: Alexander Alderman Webb Date: Tue, 29 Sep 2026 14:12:08 +0200 Subject: [PATCH 3/3] use early returns --- sentry_sdk/integrations/mcp.py | 70 ++++++++++++++++++---------------- 1 file changed, 37 insertions(+), 33 deletions(-) diff --git a/sentry_sdk/integrations/mcp.py b/sentry_sdk/integrations/mcp.py index 499441f8d8..0935fd5b1e 100644 --- a/sentry_sdk/integrations/mcp.py +++ b/sentry_sdk/integrations/mcp.py @@ -290,19 +290,21 @@ async def _instrument_tool_call( result_content = _extract_text_from_content_blocks(result["content"]) if ( - result_content is not None - and client.options["data_collection"]["gen_ai"]["outputs"] + result_content is None + or not client.options["data_collection"]["gen_ai"]["outputs"] ): + return result + + span.set_attribute( + SPANDATA.MCP_TOOL_RESULT_CONTENT, + safe_serialize(result_content), + ) + # Set content count if result is a dict + if isinstance(result_content, dict): span.set_attribute( - 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): - span.set_attribute( - SPANDATA.MCP_TOOL_RESULT_CONTENT_COUNT, - len(result_content), - ) return result @@ -379,30 +381,32 @@ async def _instrument_prompt_get( ) if ( - message_count == 1 - and client.options["data_collection"]["gen_ai"]["inputs"] - and messages + message_count != 1 + or not client.options["data_collection"]["gen_ai"]["inputs"] + or not messages ): - first_message = messages[0] - # Extract role - role = None - if "role" in first_message: - role = first_message["role"] - - if role: - span.set_attribute(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: - span.set_attribute( - SPANDATA.MCP_PROMPT_RESULT_MESSAGE_CONTENT, - content_text, - ) + return result + + first_message = messages[0] + # Extract role + role = None + if "role" in first_message: + role = first_message["role"] + + if role: + span.set_attribute(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: + span.set_attribute( + SPANDATA.MCP_PROMPT_RESULT_MESSAGE_CONTENT, + content_text, + ) except Exception: # Silently ignore if we can't extract message info pass