From a8372b00f04b7e2a3af30b0c37478eb23dc9232e Mon Sep 17 00:00:00 2001 From: Alexander Alderman Webb Date: Fri, 16 Jan 2026 15:19:46 +0100 Subject: [PATCH 01/28] test(mcp): Simulate stdio transport with memory streams --- tests/integrations/mcp/test_mcp.py | 404 +++++++++++++++++++++-------- 1 file changed, 289 insertions(+), 115 deletions(-) diff --git a/tests/integrations/mcp/test_mcp.py b/tests/integrations/mcp/test_mcp.py index 4415467cd7..8e7fdc4a1d 100644 --- a/tests/integrations/mcp/test_mcp.py +++ b/tests/integrations/mcp/test_mcp.py @@ -15,6 +15,7 @@ that the integration properly instruments MCP handlers with Sentry spans. """ +import anyio import pytest import json from unittest import mock @@ -30,6 +31,15 @@ async def __call__(self, *args, **kwargs): from mcp.server.lowlevel import Server from mcp.server.lowlevel.server import request_ctx +from mcp.types import ( + JSONRPCMessage, + JSONRPCRequest, + GetPromptResult, + PromptMessage, + TextContent, +) +from mcp.server.lowlevel.helper_types import ReadResourceContents +from mcp.shared.message import SessionMessage try: from mcp.server.lowlevel.server import request_ctx @@ -41,6 +51,70 @@ async def __call__(self, *args, **kwargs): from sentry_sdk.integrations.mcp import MCPIntegration +def get_initialization_payload(request_id: str): + return SessionMessage( + message=JSONRPCMessage( + root=JSONRPCRequest( + jsonrpc="2.0", + id=request_id, + method="initialize", + params={ + "protocolVersion": "2025-11-25", + "capabilities": {}, + "clientInfo": {"name": "test-client", "version": "1.0.0"}, + }, + ) + ) + ) + + +def get_mcp_command_payload(method: str, params, request_id: str): + return SessionMessage( + message=JSONRPCMessage( + root=JSONRPCRequest( + jsonrpc="2.0", + id=request_id, + method=method, + params=params, + ) + ) + ) + + +async def stdio(server, method: str, params, request_id: str | None = None): + if request_id is None: + request_id = "1" # arbitrary + + read_stream_writer, read_stream = anyio.create_memory_object_stream(0) + write_stream, write_stream_reader = anyio.create_memory_object_stream(0) + + result = {} + + async def run_server(): + await server.run( + read_stream, write_stream, server.create_initialization_options() + ) + + async def simulate_client(tg, result): + init_request = get_initialization_payload("1") + await read_stream_writer.send(init_request) + + await write_stream_reader.receive() + + request = get_mcp_command_payload(method, params=params, request_id=request_id) + await read_stream_writer.send(request) + + result["response"] = await write_stream_reader.receive() + + tg.cancel_scope.cancel() + + async with anyio.create_task_group() as tg: + tg.start_soon(run_server) + tg.start_soon(simulate_client, tg, result) + + return result["response"] + + @pytest.fixture(autouse=True) def reset_request_ctx(): """Reset request context before and after each test""" @@ -141,11 +215,12 @@ def test_integration_patches_server(sentry_init): assert Server.read_resource is not original_read_resource +@pytest.mark.asyncio @pytest.mark.parametrize( "send_default_pii, include_prompts", [(True, True), (True, False), (False, True), (False, False)], ) -def test_tool_handler_sync( +async def test_tool_handler_stdio( sentry_init, capture_events, send_default_pii, include_prompts ): """Test that synchronous tool handlers create proper spans""" @@ -158,19 +233,25 @@ def test_tool_handler_sync( server = Server("test-server") - # Set up mock request context - mock_ctx = MockRequestContext(request_id="req-123", transport="stdio") - request_ctx.set(mock_ctx) - @server.call_tool() - def test_tool(tool_name, arguments): + async def test_tool(tool_name, arguments): return {"result": "success", "value": 42} with start_transaction(name="mcp tx"): - # Call the tool handler - result = test_tool("calculate", {"x": 10, "y": 5}) + result = await stdio( + server, + method="tools/call", + params={ + "name": "calculate", + "arguments": {"x": 10, "y": 5}, + }, + request_id="req-123", + ) - assert result == {"result": "success", "value": 42} + assert result.message.root.result["content"][0]["text"] == json.dumps( + {"result": "success", "value": 42}, + indent=2, + ) (tx,) = events assert tx["type"] == "transaction" @@ -262,7 +343,8 @@ async def test_tool_async(tool_name, arguments): assert SPANDATA.MCP_TOOL_RESULT_CONTENT not in span["data"] -def test_tool_handler_with_error(sentry_init, capture_events): +@pytest.mark.asyncio +async def test_tool_handler_with_error(sentry_init, capture_events): """Test that tool handler errors are captured properly""" sentry_init( integrations=[MCPIntegration()], @@ -272,17 +354,23 @@ def test_tool_handler_with_error(sentry_init, capture_events): server = Server("test-server") - # Set up mock request context - mock_ctx = MockRequestContext(request_id="req-error", transport="stdio") - request_ctx.set(mock_ctx) - @server.call_tool() - def failing_tool(tool_name, arguments): + async def failing_tool(tool_name, arguments): raise ValueError("Tool execution failed") with start_transaction(name="mcp tx"): - with pytest.raises(ValueError): - failing_tool("bad_tool", {}) + result = await stdio( + server, + method="tools/call", + params={ + "name": "bad_tool", + "arguments": {}, + }, + ) + + assert ( + result.message.root.result["content"][0]["text"] == "Tool execution failed" + ) # Should have error event and transaction assert len(events) == 2 @@ -304,11 +392,12 @@ def failing_tool(tool_name, arguments): assert span["tags"]["status"] == "internal_error" +@pytest.mark.asyncio @pytest.mark.parametrize( "send_default_pii, include_prompts", [(True, True), (True, False), (False, True), (False, False)], ) -def test_prompt_handler_sync( +async def test_prompt_handler_sync( sentry_init, capture_events, send_default_pii, include_prompts ): """Test that synchronous prompt handlers create proper spans""" @@ -321,19 +410,34 @@ def test_prompt_handler_sync( server = Server("test-server") - # Set up mock request context - mock_ctx = MockRequestContext(request_id="req-prompt", transport="stdio") - request_ctx.set(mock_ctx) - @server.get_prompt() - def test_prompt(name, arguments): - return MockGetPromptResult([MockPromptMessage("user", "Tell me about Python")]) + async def test_prompt(name, arguments): + return GetPromptResult( + description="A helpful test prompt", + messages=[ + PromptMessage( + role="user", + content=TextContent(type="text", text="Tell me about Python"), + ), + ], + ) with start_transaction(name="mcp tx"): - result = test_prompt("code_help", {"language": "python"}) + result = await stdio( + server, + method="prompts/get", + params={ + "name": "code_help", + "arguments": {"language": "python"}, + }, + request_id="req-prompt", + ) - assert result.messages[0].role == "user" - assert result.messages[0].content.text == "Tell me about Python" + assert result.message.root.result["messages"][0]["role"] == "user" + assert ( + result.message.root.result["messages"][0]["content"]["text"] + == "Tell me about Python" + ) (tx,) = events assert tx["type"] == "transaction" @@ -420,7 +524,8 @@ async def test_prompt_async(name, arguments): assert SPANDATA.MCP_PROMPT_RESULT_MESSAGE_CONTENT not in span["data"] -def test_prompt_handler_with_error(sentry_init, capture_events): +@pytest.mark.asyncio +async def test_prompt_handler_with_error(sentry_init, capture_events): """Test that prompt handler errors are captured""" sentry_init( integrations=[MCPIntegration()], @@ -430,17 +535,22 @@ def test_prompt_handler_with_error(sentry_init, capture_events): server = Server("test-server") - # Set up mock request context - mock_ctx = MockRequestContext(request_id="req-error-prompt", transport="stdio") - request_ctx.set(mock_ctx) - @server.get_prompt() - def failing_prompt(name, arguments): + async def failing_prompt(name, arguments): raise RuntimeError("Prompt not found") with start_transaction(name="mcp tx"): - with pytest.raises(RuntimeError): - failing_prompt("missing_prompt", {}) + response = await stdio( + server, + method="prompts/get", + params={ + "name": "code_help", + "arguments": {"language": "python"}, + }, + request_id="req-prompt", + ) + + assert response.message.root.error.message == "Prompt not found" # Should have error event and transaction assert len(events) == 2 @@ -450,7 +560,8 @@ def failing_prompt(name, arguments): assert error_event["exception"]["values"][0]["type"] == "RuntimeError" -def test_resource_handler_sync(sentry_init, capture_events): +@pytest.mark.asyncio +async def test_resource_handler_sync(sentry_init, capture_events): """Test that synchronous resource handlers create proper spans""" sentry_init( integrations=[MCPIntegration()], @@ -460,19 +571,27 @@ def test_resource_handler_sync(sentry_init, capture_events): server = Server("test-server") - # Set up mock request context - mock_ctx = MockRequestContext(request_id="req-resource", transport="stdio") - request_ctx.set(mock_ctx) - @server.read_resource() - def test_resource(uri): - return {"content": "file contents", "mime_type": "text/plain"} + async def test_resource(uri): + return [ + ReadResourceContents( + content=json.dumps({"content": "file contents"}), mime_type="text/plain" + ) + ] with start_transaction(name="mcp tx"): - uri = MockURI("file:///path/to/file.txt") - result = test_resource(uri) + result = await stdio( + server, + method="resources/read", + params={ + "uri": "file:///path/to/file.txt", + }, + request_id="req-resource", + ) - assert result["content"] == "file contents" + assert result.message.root.result["contents"][0]["text"] == json.dumps( + {"content": "file contents"}, + ) (tx,) = events assert tx["type"] == "transaction" @@ -533,7 +652,8 @@ async def test_resource_async(uri): assert span["data"][SPANDATA.MCP_SESSION_ID] == "session-res" -def test_resource_handler_with_error(sentry_init, capture_events): +@pytest.mark.asyncio +async def test_resource_handler_with_error(sentry_init, capture_events): """Test that resource handler errors are captured""" sentry_init( integrations=[MCPIntegration()], @@ -543,18 +663,18 @@ def test_resource_handler_with_error(sentry_init, capture_events): server = Server("test-server") - # Set up mock request context - mock_ctx = MockRequestContext(request_id="req-error-resource", transport="stdio") - request_ctx.set(mock_ctx) - @server.read_resource() - def failing_resource(uri): + async def failing_resource(uri): raise FileNotFoundError("Resource not found") with start_transaction(name="mcp tx"): - with pytest.raises(FileNotFoundError): - uri = MockURI("file:///missing.txt") - failing_resource(uri) + await stdio( + server, + method="resources/read", + params={ + "uri": "file:///missing.txt", + }, + ) # Should have error event and transaction assert len(events) == 2 @@ -564,11 +684,12 @@ def failing_resource(uri): assert error_event["exception"]["values"][0]["type"] == "FileNotFoundError" +@pytest.mark.asyncio @pytest.mark.parametrize( "send_default_pii, include_prompts", [(True, True), (False, False)], ) -def test_tool_result_extraction_tuple( +async def test_tool_result_extraction_tuple( sentry_init, capture_events, send_default_pii, include_prompts ): """Test extraction of tool results from tuple format (UnstructuredContent, StructuredContent)""" @@ -581,19 +702,22 @@ def test_tool_result_extraction_tuple( server = Server("test-server") - # Set up mock request context - mock_ctx = MockRequestContext(request_id="req-tuple", transport="stdio") - request_ctx.set(mock_ctx) - @server.call_tool() - def test_tool_tuple(tool_name, arguments): + async def test_tool_tuple(tool_name, arguments): # Return CombinationContent: (UnstructuredContent, StructuredContent) unstructured = [MockTextContent("Result text")] structured = {"key": "value", "count": 5} return (unstructured, structured) with start_transaction(name="mcp tx"): - test_tool_tuple("combo_tool", {}) + await stdio( + server, + method="tools/call", + params={ + "name": "calculate", + "arguments": {"x": 10, "y": 5}, + }, + ) (tx,) = events span = tx["spans"][0] @@ -612,11 +736,12 @@ def test_tool_tuple(tool_name, arguments): assert SPANDATA.MCP_TOOL_RESULT_CONTENT_COUNT not in span["data"] +@pytest.mark.asyncio @pytest.mark.parametrize( "send_default_pii, include_prompts", [(True, True), (False, False)], ) -def test_tool_result_extraction_unstructured( +async def test_tool_result_extraction_unstructured( sentry_init, capture_events, send_default_pii, include_prompts ): """Test extraction of tool results from UnstructuredContent (list of content blocks)""" @@ -629,12 +754,8 @@ def test_tool_result_extraction_unstructured( server = Server("test-server") - # Set up mock request context - mock_ctx = MockRequestContext(request_id="req-unstructured", transport="stdio") - request_ctx.set(mock_ctx) - @server.call_tool() - def test_tool_unstructured(tool_name, arguments): + async def test_tool_unstructured(tool_name, arguments): # Return UnstructuredContent as list of content blocks return [ MockTextContent("First part"), @@ -642,7 +763,14 @@ def test_tool_unstructured(tool_name, arguments): ] with start_transaction(name="mcp tx"): - test_tool_unstructured("text_tool", {}) + await stdio( + server, + method="tools/call", + params={ + "name": "calculate", + "arguments": {"x": 10, "y": 5}, + }, + ) (tx,) = events span = tx["spans"][0] @@ -693,7 +821,8 @@ def test_tool_no_ctx(tool_name, arguments): assert SPANDATA.MCP_SESSION_ID not in span["data"] -def test_span_origin(sentry_init, capture_events): +@pytest.mark.asyncio +async def test_span_origin(sentry_init, capture_events): """Test that span origin is set correctly""" sentry_init( integrations=[MCPIntegration()], @@ -703,16 +832,19 @@ def test_span_origin(sentry_init, capture_events): server = Server("test-server") - # Set up mock request context - mock_ctx = MockRequestContext(request_id="req-origin", transport="stdio") - request_ctx.set(mock_ctx) - @server.call_tool() - def test_tool(tool_name, arguments): + async def test_tool(tool_name, arguments): return {"result": "test"} with start_transaction(name="mcp tx"): - test_tool("origin_test", {}) + await stdio( + server, + method="tools/call", + params={ + "name": "calculate", + "arguments": {"x": 10, "y": 5}, + }, + ) (tx,) = events @@ -720,7 +852,8 @@ def test_tool(tool_name, arguments): assert tx["spans"][0]["origin"] == "auto.ai.mcp" -def test_multiple_handlers(sentry_init, capture_events): +@pytest.mark.asyncio +async def test_multiple_handlers(sentry_init, capture_events): """Test that multiple handler calls create multiple spans""" sentry_init( integrations=[MCPIntegration()], @@ -730,26 +863,52 @@ def test_multiple_handlers(sentry_init, capture_events): server = Server("test-server") - # Set up mock request context - mock_ctx = MockRequestContext(request_id="req-multi", transport="stdio") - request_ctx.set(mock_ctx) - @server.call_tool() - def tool1(tool_name, arguments): + async def tool1(tool_name, arguments): return {"result": "tool1"} @server.call_tool() - def tool2(tool_name, arguments): + async def tool2(tool_name, arguments): return {"result": "tool2"} @server.get_prompt() - def prompt1(name, arguments): - return MockGetPromptResult([MockPromptMessage("user", "Test prompt")]) + async def prompt1(name, arguments): + return GetPromptResult( + description="A test prompt", + messages=[ + PromptMessage( + role="user", content=TextContent(type="text", text="Test prompt") + ) + ], + ) with start_transaction(name="mcp tx"): - tool1("tool_a", {}) - tool2("tool_b", {}) - prompt1("prompt_a", {}) + await stdio( + server, + method="tools/call", + params={ + "name": "tool_a", + "arguments": {}, + }, + ) + + await stdio( + server, + method="tools/call", + params={ + "name": "tool_b", + "arguments": {}, + }, + ) + + await stdio( + server, + method="prompts/get", + params={ + "name": "prompt_a", + "arguments": {}, + }, + ) (tx,) = events assert tx["type"] == "transaction" @@ -765,11 +924,12 @@ def prompt1(name, arguments): assert "prompts/get prompt_a" in span_descriptions +@pytest.mark.asyncio @pytest.mark.parametrize( "send_default_pii, include_prompts", [(True, True), (False, False)], ) -def test_prompt_with_dict_result( +async def test_prompt_with_dict_result( sentry_init, capture_events, send_default_pii, include_prompts ): """Test prompt handler with dict result instead of GetPromptResult object""" @@ -782,10 +942,6 @@ def test_prompt_with_dict_result( server = Server("test-server") - # Set up mock request context - mock_ctx = MockRequestContext(request_id="req-dict-prompt", transport="stdio") - request_ctx.set(mock_ctx) - @server.get_prompt() def test_prompt_dict(name, arguments): # Return dict format instead of GetPromptResult object @@ -796,7 +952,14 @@ def test_prompt_dict(name, arguments): } with start_transaction(name="mcp tx"): - test_prompt_dict("dict_prompt", {}) + await stdio( + server, + method="prompts/get", + params={ + "name": "dict_prompt", + "arguments": {}, + }, + ) (tx,) = events span = tx["spans"][0] @@ -816,7 +979,9 @@ def test_prompt_dict(name, arguments): assert SPANDATA.MCP_PROMPT_RESULT_MESSAGE_CONTENT not in span["data"] -def test_resource_without_protocol(sentry_init, capture_events): +@pytest.mark.asyncio +@pytest.mark.skip +async def test_resource_without_protocol(sentry_init, capture_events): """Test resource handler with URI without protocol scheme""" sentry_init( integrations=[MCPIntegration()], @@ -826,17 +991,18 @@ def test_resource_without_protocol(sentry_init, capture_events): server = Server("test-server") - # Set up mock request context - mock_ctx = MockRequestContext(request_id="req-no-proto", transport="stdio") - request_ctx.set(mock_ctx) - @server.read_resource() def test_resource(uri): return {"data": "test"} with start_transaction(name="mcp tx"): - # URI without protocol - test_resource("simple-path") + await stdio( + server, + method="resources/read", + params={ + "uri": "https://example.com/resource", + }, + ) (tx,) = events span = tx["spans"][0] @@ -846,7 +1012,8 @@ def test_resource(uri): assert SPANDATA.MCP_RESOURCE_PROTOCOL not in span["data"] -def test_tool_with_complex_arguments(sentry_init, capture_events): +@pytest.mark.asyncio +async def test_tool_with_complex_arguments(sentry_init, capture_events): """Test tool handler with complex nested arguments""" sentry_init( integrations=[MCPIntegration()], @@ -856,12 +1023,8 @@ def test_tool_with_complex_arguments(sentry_init, capture_events): server = Server("test-server") - # Set up mock request context - mock_ctx = MockRequestContext(request_id="req-complex", transport="stdio") - request_ctx.set(mock_ctx) - @server.call_tool() - def test_tool_complex(tool_name, arguments): + async def test_tool_complex(tool_name, arguments): return {"processed": True} with start_transaction(name="mcp tx"): @@ -870,7 +1033,14 @@ def test_tool_complex(tool_name, arguments): "string": "test", "number": 42, } - test_tool_complex("complex_tool", complex_args) + await stdio( + server, + method="tools/call", + params={ + "name": "complex_tool", + "arguments": complex_args, + }, + ) (tx,) = events span = tx["spans"][0] @@ -988,7 +1158,8 @@ def test_tool(tool_name, arguments): assert span["data"][SPANDATA.MCP_SESSION_ID] == "session-http-456" -def test_stdio_transport_detection(sentry_init, capture_events): +@pytest.mark.asyncio +async def test_stdio_transport_detection(sentry_init, capture_events): """Test that stdio transport is correctly detected when no HTTP request""" sentry_init( integrations=[MCPIntegration()], @@ -998,18 +1169,21 @@ def test_stdio_transport_detection(sentry_init, capture_events): server = Server("test-server") - # Set up mock request context with stdio transport (no HTTP request) - mock_ctx = MockRequestContext(request_id="req-stdio", transport="stdio") - request_ctx.set(mock_ctx) - @server.call_tool() - def test_tool(tool_name, arguments): + async def test_tool(tool_name, arguments): return {"result": "success"} with start_transaction(name="mcp tx"): - result = test_tool("stdio_tool", {}) + result = await stdio( + server, + method="tools/call", + params={ + "name": "stdio_tool", + "arguments": {}, + }, + ) - assert result == {"result": "success"} + assert result.message.root.result["structuredContent"] == {"result": "success"} (tx,) = events span = tx["spans"][0] From 87a2e267493f147b172c875603cad9d85dc28e31 Mon Sep 17 00:00:00 2001 From: Alexander Alderman Webb Date: Mon, 19 Jan 2026 09:47:42 +0100 Subject: [PATCH 02/28] test(fastmcp): Simulate stdio transport with memory streams --- tests/conftest.py | 92 ++++++ tests/integrations/fastmcp/test_fastmcp.py | 314 +++++++++++++++------ tests/integrations/mcp/test_mcp.py | 1 + 3 files changed, 314 insertions(+), 93 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index dea36f8bda..28bd4e8ab5 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -48,6 +48,20 @@ from typing import Optional from collections.abc import Iterator +try: + from anyio import create_memory_object_stream, create_task_group + from mcp.types import ( + JSONRPCMessage, + JSONRPCRequest, + ) + from mcp.shared.message import SessionMessage +except ImportError: + create_memory_object_stream = None + create_task_group = None + JSONRPCMessage = None + JSONRPCRequest = None + SessionMessage = None + SENTRY_EVENT_SCHEMA = "./checkouts/data-schemas/relay/event.schema.json" @@ -592,6 +606,84 @@ def suppress_deprecation_warnings(): yield +@pytest.fixture +def get_initialization_payload(): + def inner(request_id: str): + return SessionMessage( # type: ignore + message=JSONRPCMessage( # type: ignore + root=JSONRPCRequest( # type: ignore + jsonrpc="2.0", + id=request_id, + method="initialize", + params={ + "protocolVersion": "2025-11-25", + "capabilities": {}, + "clientInfo": {"name": "test-client", "version": "1.0.0"}, + }, + ) + ) + ) + + return inner + + +@pytest.fixture +def get_mcp_command_payload(): + def inner(method: str, params, request_id: str): + return SessionMessage( # type: ignore + message=JSONRPCMessage( # type: ignore + root=JSONRPCRequest( # type: ignore + jsonrpc="2.0", + id=request_id, + method=method, + params=params, + ) + ) + ) + + return inner + + +@pytest.fixture +def stdio(get_initialization_payload, get_mcp_command_payload): + async def inner(server, method: str, params, request_id: str | None = None): + if request_id is None: + request_id = "1" # arbitrary + + read_stream_writer, read_stream = create_memory_object_stream(0) # type: ignore + write_stream, write_stream_reader = create_memory_object_stream(0) # type: ignore + + result = {} + + async def run_server(): + await server.run( + read_stream, write_stream, server.create_initialization_options() + ) + + async def simulate_client(tg, result): + init_request = get_initialization_payload("1") + await read_stream_writer.send(init_request) + + await write_stream_reader.receive() + + request = get_mcp_command_payload( + method, params=params, request_id=request_id + ) + await read_stream_writer.send(request) + + result["response"] = await write_stream_reader.receive() + + tg.cancel_scope.cancel() + + async with create_task_group() as tg: # type: ignore + tg.start_soon(run_server) + tg.start_soon(simulate_client, tg, result) + + return result["response"] + + return inner + + class MockServerRequestHandler(BaseHTTPRequestHandler): def do_GET(self): # noqa: N802 # Process an HTTP GET request and return a response. diff --git a/tests/integrations/fastmcp/test_fastmcp.py b/tests/integrations/fastmcp/test_fastmcp.py index ef2a1f9cb7..7ef4e1c35a 100644 --- a/tests/integrations/fastmcp/test_fastmcp.py +++ b/tests/integrations/fastmcp/test_fastmcp.py @@ -72,6 +72,12 @@ async def __call__(self, *args, **kwargs): ReadResourceRequest = None +try: + from fastmcp import __version__ as FASTMCP_VERSION +except ImportError: + FASTMCP_VERSION = None + + # Collect available FastMCP implementations for parametrization fastmcp_implementations = [] fastmcp_ids = [] @@ -278,13 +284,14 @@ def __init__(self, session_id=None, transport="http"): # ============================================================================= +@pytest.mark.asyncio @pytest.mark.parametrize("FastMCP", fastmcp_implementations, ids=fastmcp_ids) @pytest.mark.parametrize( "send_default_pii, include_prompts", [(True, True), (True, False), (False, True), (False, False)], ) -def test_fastmcp_tool_sync( - sentry_init, capture_events, FastMCP, send_default_pii, include_prompts +async def test_fastmcp_tool_sync( + sentry_init, capture_events, FastMCP, send_default_pii, include_prompts, stdio ): """Test that FastMCP synchronous tool handlers create proper spans""" sentry_init( @@ -296,11 +303,6 @@ def test_fastmcp_tool_sync( mcp = FastMCP("Test Server") - # Set up mock request context - if request_ctx is not None: - mock_ctx = MockRequestContext(request_id="req-123", transport="stdio") - request_ctx.set(mock_ctx) - @mcp.tool() def add_numbers(a: int, b: int) -> dict: """Add two numbers together""" @@ -308,9 +310,34 @@ def add_numbers(a: int, b: int) -> dict: with start_transaction(name="fastmcp tx"): # Call through MCP protocol to trigger instrumentation - result = call_tool_through_mcp(mcp, "add_numbers", {"a": 10, "b": 5}) + result = await stdio( + mcp._mcp_server, + method="tools/call", + params={ + "name": "add_numbers", + "arguments": {"a": 10, "b": 5}, + }, + request_id="req-123", + ) - assert result == {"result": 15, "operation": "addition"} + if ( + isinstance(mcp, StandaloneFastMCP) + and FASTMCP_VERSION is not None + and FASTMCP_VERSION.startswith("2") + ): + assert result.message.root.result["content"][0]["text"] == json.dumps( + {"result": 15, "operation": "addition"}, separators=(",", ":") + ) + elif ( + isinstance(mcp, StandaloneFastMCP) and FASTMCP_VERSION is not None + ): # Checking for None is not precise. + assert result.message.root.result["content"][0]["text"] == json.dumps( + {"result": 15, "operation": "addition"} + ) + else: + assert result.message.root.result["content"][0]["text"] == json.dumps( + {"result": 15, "operation": "addition"}, indent=2 + ) (tx,) = events assert tx["type"] == "transaction" @@ -393,8 +420,9 @@ async def multiply_numbers(x: int, y: int) -> dict: assert SPANDATA.MCP_TOOL_RESULT_CONTENT not in span["data"] +@pytest.mark.asyncio @pytest.mark.parametrize("FastMCP", fastmcp_implementations, ids=fastmcp_ids) -def test_fastmcp_tool_with_error(sentry_init, capture_events, FastMCP): +async def test_fastmcp_tool_with_error(sentry_init, capture_events, FastMCP, stdio): """Test that FastMCP tool handler errors are captured properly""" sentry_init( integrations=[MCPIntegration()], @@ -404,26 +432,23 @@ def test_fastmcp_tool_with_error(sentry_init, capture_events, FastMCP): mcp = FastMCP("Test Server") - # Set up mock request context - if request_ctx is not None: - mock_ctx = MockRequestContext(request_id="req-error", transport="stdio") - request_ctx.set(mock_ctx) - @mcp.tool() def failing_tool(value: int) -> int: """A tool that always fails""" raise ValueError("Tool execution failed") with start_transaction(name="fastmcp tx"): - # MCP protocol may raise the error or return it as an error result - try: - result = call_tool_through_mcp(mcp, "failing_tool", {"value": 42}) - # If no exception raised, check if result indicates error - if hasattr(result, "isError"): - assert result.isError is True - except ValueError: - # Error was raised as expected - pass + result = await stdio( + mcp._mcp_server, + method="tools/call", + params={ + "name": "failing_tool", + "arguments": {"value": 42}, + }, + request_id="req-123", + ) + # If no exception raised, check if result indicates error + assert result.message.root.result["isError"] is True # Should have transaction and error events assert len(events) >= 1 @@ -443,8 +468,9 @@ def failing_tool(value: int) -> int: assert tool_spans[0]["data"][SPANDATA.MCP_TOOL_RESULT_IS_ERROR] is True +@pytest.mark.asyncio @pytest.mark.parametrize("FastMCP", fastmcp_implementations, ids=fastmcp_ids) -def test_fastmcp_multiple_tools(sentry_init, capture_events, FastMCP): +async def test_fastmcp_multiple_tools(sentry_init, capture_events, FastMCP, stdio): """Test that multiple FastMCP tool calls create multiple spans""" sentry_init( integrations=[MCPIntegration()], @@ -454,11 +480,6 @@ def test_fastmcp_multiple_tools(sentry_init, capture_events, FastMCP): mcp = FastMCP("Test Server") - # Set up mock request context - if request_ctx is not None: - mock_ctx = MockRequestContext(request_id="req-multi", transport="stdio") - request_ctx.set(mock_ctx) - @mcp.tool() def tool_one(x: int) -> int: """First tool""" @@ -475,13 +496,43 @@ def tool_three(z: int) -> int: return z - 5 with start_transaction(name="fastmcp tx"): - result1 = call_tool_through_mcp(mcp, "tool_one", {"x": 5}) - result2 = call_tool_through_mcp(mcp, "tool_two", {"y": result1["result"]}) - result3 = call_tool_through_mcp(mcp, "tool_three", {"z": result2["result"]}) + result1 = await stdio( + mcp._mcp_server, + method="tools/call", + params={ + "name": "tool_one", + "arguments": {"x": 5}, + }, + request_id="req-123", + ) + + result2 = await stdio( + mcp._mcp_server, + method="tools/call", + params={ + "name": "tool_two", + "arguments": { + "y": int(result1.message.root.result["content"][0]["text"]) + }, + }, + request_id="req-123", + ) + + result3 = await stdio( + mcp._mcp_server, + method="tools/call", + params={ + "name": "tool_three", + "arguments": { + "z": int(result2.message.root.result["content"][0]["text"]) + }, + }, + request_id="req-123", + ) - assert result1["result"] == 10 - assert result2["result"] == 20 - assert result3["result"] == 15 + assert result1.message.root.result["content"][0]["text"] == "10" + assert result2.message.root.result["content"][0]["text"] == "20" + assert result3.message.root.result["content"][0]["text"] == "15" (tx,) = events assert tx["type"] == "transaction" @@ -494,8 +545,11 @@ def tool_three(z: int) -> int: assert tool_spans[2]["data"][SPANDATA.MCP_TOOL_NAME] == "tool_three" +@pytest.mark.asyncio @pytest.mark.parametrize("FastMCP", fastmcp_implementations, ids=fastmcp_ids) -def test_fastmcp_tool_with_complex_return(sentry_init, capture_events, FastMCP): +async def test_fastmcp_tool_with_complex_return( + sentry_init, capture_events, FastMCP, stdio +): """Test FastMCP tool with complex nested return value""" sentry_init( integrations=[MCPIntegration(include_prompts=True)], @@ -506,11 +560,6 @@ def test_fastmcp_tool_with_complex_return(sentry_init, capture_events, FastMCP): mcp = FastMCP("Test Server") - # Set up mock request context - if request_ctx is not None: - mock_ctx = MockRequestContext(request_id="req-complex", transport="stdio") - request_ctx.set(mock_ctx) - @mcp.tool() def get_user_data(user_id: int) -> dict: """Get complex user data""" @@ -522,11 +571,50 @@ def get_user_data(user_id: int) -> dict: } with start_transaction(name="fastmcp tx"): - result = call_tool_through_mcp(mcp, "get_user_data", {"user_id": 123}) + result = await stdio( + mcp._mcp_server, + method="tools/call", + params={ + "name": "get_user_data", + "arguments": {"user_id": 123}, + }, + ) - assert result["id"] == 123 - assert result["name"] == "Alice" - assert result["nested"]["preferences"]["theme"] == "dark" + if ( + isinstance(mcp, StandaloneFastMCP) + and FASTMCP_VERSION is not None + and FASTMCP_VERSION.startswith("2") + ): + assert result.message.root.result["content"][0]["text"] == json.dumps( + { + "id": 123, + "name": "Alice", + "nested": {"preferences": {"theme": "dark", "notifications": True}}, + "tags": ["admin", "verified"], + }, + separators=(",", ":"), + ) + elif ( + isinstance(mcp, StandaloneFastMCP) and FASTMCP_VERSION is not None + ): # Checking for None is not precise. + assert result.message.root.result["content"][0]["text"] == json.dumps( + { + "id": 123, + "name": "Alice", + "nested": {"preferences": {"theme": "dark", "notifications": True}}, + "tags": ["admin", "verified"], + } + ) + else: + assert result.message.root.result["content"][0]["text"] == json.dumps( + { + "id": 123, + "name": "Alice", + "nested": {"preferences": {"theme": "dark", "notifications": True}}, + "tags": ["admin", "verified"], + }, + indent=2, + ) (tx,) = events assert tx["type"] == "transaction" @@ -545,13 +633,14 @@ 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, include_prompts", [(True, True), (False, False)], ) -def test_fastmcp_prompt_sync( - sentry_init, capture_events, FastMCP, send_default_pii, include_prompts +async def test_fastmcp_prompt_sync( + sentry_init, capture_events, FastMCP, send_default_pii, include_prompts, stdio ): """Test that FastMCP synchronous prompt handlers create proper spans""" sentry_init( @@ -563,11 +652,6 @@ def test_fastmcp_prompt_sync( mcp = FastMCP("Test Server") - # Set up mock request context - if request_ctx is not None: - mock_ctx = MockRequestContext(request_id="req-prompt", transport="stdio") - request_ctx.set(mock_ctx) - # Try to register a prompt handler (may not be supported in all versions) try: if hasattr(mcp, "prompt"): @@ -586,12 +670,20 @@ def code_help_prompt(language: str): ] with start_transaction(name="fastmcp tx"): - result = call_prompt_through_mcp( - mcp, "code_help_prompt", {"language": "python"} + result = await stdio( + mcp._mcp_server, + method="prompts/get", + params={ + "name": "code_help_prompt", + "arguments": {"language": "python"}, + }, ) - assert result.messages[0].role == "user" - assert "python" in result.messages[0].content.text.lower() + assert result.message.root.result["messages"][0]["role"] == "user" + assert ( + "python" + in result.message.root.result["messages"][0]["content"]["text"].lower() + ) (tx,) = events assert tx["type"] == "transaction" @@ -606,9 +698,9 @@ def code_help_prompt(language: str): # Check PII-sensitive data if send_default_pii and include_prompts: - assert SPANDATA.MCP_PROMPT_CONTENT in span["data"] + assert SPANDATA.MCP_PROMPT_RESULT_MESSAGE_CONTENT in span["data"] else: - assert SPANDATA.MCP_PROMPT_CONTENT not in span["data"] + assert SPANDATA.MCP_PROMPT_RESULT_MESSAGE_CONTENT not in span["data"] except AttributeError: # Prompt handler not supported in this version pytest.skip("Prompt handlers not supported in this FastMCP version") @@ -673,8 +765,9 @@ async def async_prompt(topic: str): # ============================================================================= +@pytest.mark.asyncio @pytest.mark.parametrize("FastMCP", fastmcp_implementations, ids=fastmcp_ids) -def test_fastmcp_resource_sync(sentry_init, capture_events, FastMCP): +async def test_fastmcp_resource_sync(sentry_init, capture_events, FastMCP, stdio): """Test that FastMCP synchronous resource handlers create proper spans""" sentry_init( integrations=[MCPIntegration()], @@ -684,11 +777,6 @@ def test_fastmcp_resource_sync(sentry_init, capture_events, FastMCP): mcp = FastMCP("Test Server") - # Set up mock request context - if request_ctx is not None: - mock_ctx = MockRequestContext(request_id="req-resource", transport="stdio") - request_ctx.set(mock_ctx) - # Try to register a resource handler try: if hasattr(mcp, "resource"): @@ -700,7 +788,14 @@ def read_file(path: str): with start_transaction(name="fastmcp tx"): try: - result = call_resource_through_mcp(mcp, "file:///test.txt") + result = 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): @@ -710,7 +805,7 @@ def read_file(path: str): raise # Resource content is returned as-is - assert "file contents" in result.contents[0].text + assert "file contents" in result.message.root.result["contents"][0]["text"] (tx,) = events assert tx["type"] == "transaction" @@ -787,8 +882,9 @@ async def read_url(resource: str): # ============================================================================= +@pytest.mark.asyncio @pytest.mark.parametrize("FastMCP", fastmcp_implementations, ids=fastmcp_ids) -def test_fastmcp_span_origin(sentry_init, capture_events, FastMCP): +async def test_fastmcp_span_origin(sentry_init, capture_events, FastMCP, stdio): """Test that FastMCP span origin is set correctly""" sentry_init( integrations=[MCPIntegration()], @@ -798,18 +894,20 @@ def test_fastmcp_span_origin(sentry_init, capture_events, FastMCP): mcp = FastMCP("Test Server") - # Set up mock request context - if request_ctx is not None: - mock_ctx = MockRequestContext(request_id="req-origin", transport="stdio") - request_ctx.set(mock_ctx) - @mcp.tool() def test_tool(value: int) -> int: """Test tool for origin checking""" return value * 2 with start_transaction(name="fastmcp tx"): - call_tool_through_mcp(mcp, "test_tool", {"value": 21}) + await stdio( + mcp._mcp_server, + method="tools/call", + params={ + "name": "test_tool", + "arguments": {"value": 21}, + }, + ) (tx,) = events @@ -932,8 +1030,9 @@ def http_tool(data: str) -> dict: assert span["data"].get(SPANDATA.MCP_TRANSPORT) == "http" +@pytest.mark.asyncio @pytest.mark.parametrize("FastMCP", fastmcp_implementations, ids=fastmcp_ids) -def test_fastmcp_stdio_transport(sentry_init, capture_events, FastMCP): +async def test_fastmcp_stdio_transport(sentry_init, capture_events, FastMCP, stdio): """Test that FastMCP correctly detects stdio transport""" sentry_init( integrations=[MCPIntegration()], @@ -943,20 +1042,39 @@ def test_fastmcp_stdio_transport(sentry_init, capture_events, FastMCP): mcp = FastMCP("Test Server") - # Set up mock request context with stdio transport - if request_ctx is not None: - mock_ctx = MockRequestContext(request_id="req-stdio", transport="stdio") - request_ctx.set(mock_ctx) - @mcp.tool() def stdio_tool(n: int) -> dict: """Tool for stdio transport test""" return {"squared": n * n} with start_transaction(name="fastmcp tx"): - result = call_tool_through_mcp(mcp, "stdio_tool", {"n": 7}) + result = await stdio( + mcp._mcp_server, + method="tools/call", + params={ + "name": "stdio_tool", + "arguments": {"n": 7}, + }, + ) - assert result == {"squared": 49} + if ( + isinstance(mcp, StandaloneFastMCP) + and FASTMCP_VERSION is not None + and FASTMCP_VERSION.startswith("2") + ): + assert result.message.root.result["content"][0]["text"] == json.dumps( + {"squared": 49}, separators=(",", ":") + ) + elif ( + isinstance(mcp, StandaloneFastMCP) and FASTMCP_VERSION is not None + ): # Checking for None is not precise. + assert result.message.root.result["content"][0]["text"] == json.dumps( + {"squared": 49} + ) + else: + assert result.message.root.result["content"][0]["text"] == json.dumps( + {"squared": 49}, indent=2 + ) (tx,) = events @@ -1088,9 +1206,12 @@ def none_return_tool(action: str) -> None: assert tx["type"] == "transaction" +@pytest.mark.asyncio @pytest.mark.parametrize("FastMCP", fastmcp_implementations, ids=fastmcp_ids) @pytest.mark.asyncio -async def test_fastmcp_mixed_sync_async_tools(sentry_init, capture_events, FastMCP): +async def test_fastmcp_mixed_sync_async_tools( + sentry_init, capture_events, FastMCP, stdio +): """Test mixing sync and async tools in FastMCP""" sentry_init( integrations=[MCPIntegration()], @@ -1100,11 +1221,6 @@ async def test_fastmcp_mixed_sync_async_tools(sentry_init, capture_events, FastM mcp = FastMCP("Test Server") - # Set up mock request context - if request_ctx is not None: - mock_ctx = MockRequestContext(request_id="req-mixed", transport="stdio") - request_ctx.set(mock_ctx) - @mcp.tool() def sync_add(a: int, b: int) -> int: """Sync addition""" @@ -1117,13 +1233,25 @@ async def async_multiply(x: int, y: int) -> int: with start_transaction(name="fastmcp tx"): # Use async version for both since we're in an async context - result1 = await call_tool_through_mcp_async(mcp, "sync_add", {"a": 3, "b": 4}) - result2 = await call_tool_through_mcp_async( - mcp, "async_multiply", {"x": 5, "y": 6} + result1 = await stdio( + mcp._mcp_server, + method="tools/call", + params={ + "name": "sync_add", + "arguments": {"a": 3, "b": 4}, + }, + ) + result2 = await stdio( + mcp._mcp_server, + method="tools/call", + params={ + "name": "async_multiply", + "arguments": {"x": 5, "y": 6}, + }, ) - assert result1["result"] == 7 - assert result2["result"] == 30 + assert result1.message.root.result["content"][0]["text"] == "7" + assert result2.message.root.result["content"][0]["text"] == "30" (tx,) = events assert tx["type"] == "transaction" diff --git a/tests/integrations/mcp/test_mcp.py b/tests/integrations/mcp/test_mcp.py index 8e7fdc4a1d..05fd6bc52c 100644 --- a/tests/integrations/mcp/test_mcp.py +++ b/tests/integrations/mcp/test_mcp.py @@ -15,6 +15,7 @@ that the integration properly instruments MCP handlers with Sentry spans. """ +import asyncio import anyio import pytest import json From 70edb6d15344a49735ee29264dadba13b3af6b77 Mon Sep 17 00:00:00 2001 From: Alexander Alderman Webb Date: Tue, 27 Jan 2026 13:13:31 +0100 Subject: [PATCH 03/28] cleaning up --- tests/integrations/mcp/test_mcp.py | 80 ++++++++++-------------------- 1 file changed, 26 insertions(+), 54 deletions(-) diff --git a/tests/integrations/mcp/test_mcp.py b/tests/integrations/mcp/test_mcp.py index 8e7fdc4a1d..1784998303 100644 --- a/tests/integrations/mcp/test_mcp.py +++ b/tests/integrations/mcp/test_mcp.py @@ -81,10 +81,7 @@ def get_mcp_command_payload(method: str, params, request_id: str): ) -async def stdio(server, method: str, params, request_id: str | None = None): - if request_id is None: - request_id = "1" # arbitrary - +async def stdio(server, method: str, params, request_id: str): read_stream_writer, read_stream = anyio.create_memory_object_stream(0) write_stream, write_stream_reader = anyio.create_memory_object_stream(0) @@ -355,7 +352,7 @@ async def test_tool_handler_with_error(sentry_init, capture_events): server = Server("test-server") @server.call_tool() - async def failing_tool(tool_name, arguments): + def failing_tool(tool_name, arguments): raise ValueError("Tool execution failed") with start_transaction(name="mcp tx"): @@ -366,6 +363,7 @@ async def failing_tool(tool_name, arguments): "name": "bad_tool", "arguments": {}, }, + request_id="req-error", ) assert ( @@ -547,7 +545,7 @@ async def failing_prompt(name, arguments): "name": "code_help", "arguments": {"language": "python"}, }, - request_id="req-prompt", + request_id="req-error-prompt", ) assert response.message.root.error.message == "Prompt not found" @@ -664,7 +662,7 @@ async def test_resource_handler_with_error(sentry_init, capture_events): server = Server("test-server") @server.read_resource() - async def failing_resource(uri): + def failing_resource(uri): raise FileNotFoundError("Resource not found") with start_transaction(name="mcp tx"): @@ -674,6 +672,7 @@ async def failing_resource(uri): params={ "uri": "file:///missing.txt", }, + request_id="req-error-resource", ) # Should have error event and transaction @@ -703,7 +702,7 @@ async def test_tool_result_extraction_tuple( server = Server("test-server") @server.call_tool() - async def test_tool_tuple(tool_name, arguments): + def test_tool_tuple(tool_name, arguments): # Return CombinationContent: (UnstructuredContent, StructuredContent) unstructured = [MockTextContent("Result text")] structured = {"key": "value", "count": 5} @@ -715,8 +714,9 @@ async def test_tool_tuple(tool_name, arguments): method="tools/call", params={ "name": "calculate", - "arguments": {"x": 10, "y": 5}, + "arguments": {}, }, + request_id="req-tuple", ) (tx,) = events @@ -755,7 +755,7 @@ async def test_tool_result_extraction_unstructured( server = Server("test-server") @server.call_tool() - async def test_tool_unstructured(tool_name, arguments): + def test_tool_unstructured(tool_name, arguments): # Return UnstructuredContent as list of content blocks return [ MockTextContent("First part"), @@ -767,9 +767,10 @@ async def test_tool_unstructured(tool_name, arguments): server, method="tools/call", params={ - "name": "calculate", - "arguments": {"x": 10, "y": 5}, + "name": "text_tool", + "arguments": {}, }, + request_id="req-unstructured", ) (tx,) = events @@ -784,43 +785,6 @@ async def test_tool_unstructured(tool_name, arguments): assert SPANDATA.MCP_TOOL_RESULT_CONTENT not in span["data"] -def test_request_context_no_context(sentry_init, capture_events): - """Test handling when no request context is available""" - sentry_init( - integrations=[MCPIntegration()], - traces_sample_rate=1.0, - ) - events = capture_events() - - server = Server("test-server") - - # Clear request context (simulating no context available) - # This will cause a LookupError when trying to get context - request_ctx.set(None) - - @server.call_tool() - def test_tool_no_ctx(tool_name, arguments): - return {"result": "ok"} - - with start_transaction(name="mcp tx"): - # This should work even without request context - try: - test_tool_no_ctx("tool", {}) - except LookupError: - # If it raises LookupError, that's expected when context is truly missing - pass - - # Should still create span even if context is missing - (tx,) = events - span = tx["spans"][0] - - # Transport defaults to "pipe" when no context - assert span["data"][SPANDATA.MCP_TRANSPORT] == "stdio" - # Request ID and Session ID should not be present - assert SPANDATA.MCP_REQUEST_ID not in span["data"] - assert SPANDATA.MCP_SESSION_ID not in span["data"] - - @pytest.mark.asyncio async def test_span_origin(sentry_init, capture_events): """Test that span origin is set correctly""" @@ -833,7 +797,7 @@ async def test_span_origin(sentry_init, capture_events): server = Server("test-server") @server.call_tool() - async def test_tool(tool_name, arguments): + def test_tool(tool_name, arguments): return {"result": "test"} with start_transaction(name="mcp tx"): @@ -844,6 +808,7 @@ async def test_tool(tool_name, arguments): "name": "calculate", "arguments": {"x": 10, "y": 5}, }, + request_id="req-origin", ) (tx,) = events @@ -864,15 +829,15 @@ async def test_multiple_handlers(sentry_init, capture_events): server = Server("test-server") @server.call_tool() - async def tool1(tool_name, arguments): + def tool1(tool_name, arguments): return {"result": "tool1"} @server.call_tool() - async def tool2(tool_name, arguments): + def tool2(tool_name, arguments): return {"result": "tool2"} @server.get_prompt() - async def prompt1(name, arguments): + def prompt1(name, arguments): return GetPromptResult( description="A test prompt", messages=[ @@ -890,6 +855,7 @@ async def prompt1(name, arguments): "name": "tool_a", "arguments": {}, }, + request_id="req-multi", ) await stdio( @@ -899,6 +865,7 @@ async def prompt1(name, arguments): "name": "tool_b", "arguments": {}, }, + request_id="req-multi", ) await stdio( @@ -908,6 +875,7 @@ async def prompt1(name, arguments): "name": "prompt_a", "arguments": {}, }, + request_id="req-multi", ) (tx,) = events @@ -959,6 +927,7 @@ def test_prompt_dict(name, arguments): "name": "dict_prompt", "arguments": {}, }, + request_id="req-dict-prompt", ) (tx,) = events @@ -1002,6 +971,7 @@ def test_resource(uri): params={ "uri": "https://example.com/resource", }, + request_id="req-no-proto", ) (tx,) = events @@ -1024,7 +994,7 @@ async def test_tool_with_complex_arguments(sentry_init, capture_events): server = Server("test-server") @server.call_tool() - async def test_tool_complex(tool_name, arguments): + def test_tool_complex(tool_name, arguments): return {"processed": True} with start_transaction(name="mcp tx"): @@ -1040,6 +1010,7 @@ async def test_tool_complex(tool_name, arguments): "name": "complex_tool", "arguments": complex_args, }, + request_id="req-complex", ) (tx,) = events @@ -1181,6 +1152,7 @@ async def test_tool(tool_name, arguments): "name": "stdio_tool", "arguments": {}, }, + request_id="req-stdio", ) assert result.message.root.result["structuredContent"] == {"result": "success"} From f1362bd307d3efc7764af09ba88d9d9190b19a71 Mon Sep 17 00:00:00 2001 From: Alexander Alderman Webb Date: Tue, 27 Jan 2026 13:41:38 +0100 Subject: [PATCH 04/28] send initialization response --- tests/integrations/mcp/test_mcp.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tests/integrations/mcp/test_mcp.py b/tests/integrations/mcp/test_mcp.py index 1784998303..3479590cfe 100644 --- a/tests/integrations/mcp/test_mcp.py +++ b/tests/integrations/mcp/test_mcp.py @@ -33,6 +33,7 @@ async def __call__(self, *args, **kwargs): from mcp.server.lowlevel.server import request_ctx from mcp.types import ( JSONRPCMessage, + JSONRPCNotification, JSONRPCRequest, GetPromptResult, PromptMessage, @@ -68,6 +69,17 @@ def get_initialization_payload(request_id: str): ) +def get_initialized_notification_payload(): + return SessionMessage( + message=JSONRPCMessage( + root=JSONRPCNotification( + jsonrpc="2.0", + method="notifications/initialized", + ) + ) + ) + + def get_mcp_command_payload(method: str, params, request_id: str): return SessionMessage( message=JSONRPCMessage( @@ -98,6 +110,9 @@ async def simulate_client(tg, result): await write_stream_reader.receive() + initialized_notification = get_initialized_notification_payload() + await read_stream_writer.send(initialized_notification) + request = get_mcp_command_payload(method, params=params, request_id=request_id) await read_stream_writer.send(request) From 46659c5a29d90f548ec927c9a32e3519a5d0a3eb Mon Sep 17 00:00:00 2001 From: Alexander Alderman Webb Date: Tue, 27 Jan 2026 14:04:06 +0100 Subject: [PATCH 05/28] cleanup --- tests/conftest.py | 37 ++++++-- tests/integrations/fastmcp/test_fastmcp.py | 14 ++- tests/integrations/mcp/test_mcp.py | 105 +++------------------ 3 files changed, 53 insertions(+), 103 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 28bd4e8ab5..0164a77681 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,3 +1,4 @@ +import anyio import json import os import socket @@ -52,6 +53,7 @@ from anyio import create_memory_object_stream, create_task_group from mcp.types import ( JSONRPCMessage, + JSONRPCNotification, JSONRPCRequest, ) from mcp.shared.message import SessionMessage @@ -627,6 +629,21 @@ def inner(request_id: str): return inner +@pytest.fixture +def get_initialized_notification_payload(): + def inner(): + return SessionMessage( # type: ignore + message=JSONRPCMessage( # type: ignore + root=JSONRPCNotification( # type: ignore + jsonrpc="2.0", + method="notifications/initialized", + ) + ) + ) + + return inner + + @pytest.fixture def get_mcp_command_payload(): def inner(method: str, params, request_id: str): @@ -645,13 +662,14 @@ def inner(method: str, params, request_id: str): @pytest.fixture -def stdio(get_initialization_payload, get_mcp_command_payload): - async def inner(server, method: str, params, request_id: str | None = None): - if request_id is None: - request_id = "1" # arbitrary - - read_stream_writer, read_stream = create_memory_object_stream(0) # type: ignore - write_stream, write_stream_reader = create_memory_object_stream(0) # type: ignore +def stdio( + get_initialization_payload, + get_initialized_notification_payload, + get_mcp_command_payload, +): + async def inner(server, method: str, params, request_id: str): + read_stream_writer, read_stream = anyio.create_memory_object_stream(0) + write_stream, write_stream_reader = anyio.create_memory_object_stream(0) result = {} @@ -666,6 +684,9 @@ async def simulate_client(tg, result): await write_stream_reader.receive() + initialized_notification = get_initialized_notification_payload() + await read_stream_writer.send(initialized_notification) + request = get_mcp_command_payload( method, params=params, request_id=request_id ) @@ -675,7 +696,7 @@ async def simulate_client(tg, result): tg.cancel_scope.cancel() - async with create_task_group() as tg: # type: ignore + async with anyio.create_task_group() as tg: tg.start_soon(run_server) tg.start_soon(simulate_client, tg, result) diff --git a/tests/integrations/fastmcp/test_fastmcp.py b/tests/integrations/fastmcp/test_fastmcp.py index 7ef4e1c35a..0c82610b4d 100644 --- a/tests/integrations/fastmcp/test_fastmcp.py +++ b/tests/integrations/fastmcp/test_fastmcp.py @@ -445,7 +445,7 @@ def failing_tool(value: int) -> int: "name": "failing_tool", "arguments": {"value": 42}, }, - request_id="req-123", + request_id="req-error", ) # If no exception raised, check if result indicates error assert result.message.root.result["isError"] is True @@ -503,7 +503,7 @@ def tool_three(z: int) -> int: "name": "tool_one", "arguments": {"x": 5}, }, - request_id="req-123", + request_id="req-multi", ) result2 = await stdio( @@ -515,7 +515,7 @@ def tool_three(z: int) -> int: "y": int(result1.message.root.result["content"][0]["text"]) }, }, - request_id="req-123", + request_id="req-multi", ) result3 = await stdio( @@ -527,7 +527,7 @@ def tool_three(z: int) -> int: "z": int(result2.message.root.result["content"][0]["text"]) }, }, - request_id="req-123", + request_id="req-multi", ) assert result1.message.root.result["content"][0]["text"] == "10" @@ -578,6 +578,7 @@ def get_user_data(user_id: int) -> dict: "name": "get_user_data", "arguments": {"user_id": 123}, }, + request_id="req-complex", ) if ( @@ -677,6 +678,7 @@ def code_help_prompt(language: str): "name": "code_help_prompt", "arguments": {"language": "python"}, }, + request_id="req-prompt", ) assert result.message.root.result["messages"][0]["role"] == "user" @@ -907,6 +909,7 @@ def test_tool(value: int) -> int: "name": "test_tool", "arguments": {"value": 21}, }, + request_id="req-origin", ) (tx,) = events @@ -1055,6 +1058,7 @@ def stdio_tool(n: int) -> dict: "name": "stdio_tool", "arguments": {"n": 7}, }, + request_id="req-stdio", ) if ( @@ -1240,6 +1244,7 @@ async def async_multiply(x: int, y: int) -> int: "name": "sync_add", "arguments": {"a": 3, "b": 4}, }, + request_id="req-mixed", ) result2 = await stdio( mcp._mcp_server, @@ -1248,6 +1253,7 @@ async def async_multiply(x: int, y: int) -> int: "name": "async_multiply", "arguments": {"x": 5, "y": 6}, }, + request_id="req-mixed", ) assert result1.message.root.result["content"][0]["text"] == "7" diff --git a/tests/integrations/mcp/test_mcp.py b/tests/integrations/mcp/test_mcp.py index 392caacca0..7358c3e6b4 100644 --- a/tests/integrations/mcp/test_mcp.py +++ b/tests/integrations/mcp/test_mcp.py @@ -15,8 +15,6 @@ that the integration properly instruments MCP handlers with Sentry spans. """ -import asyncio -import anyio import pytest import json from unittest import mock @@ -53,81 +51,6 @@ async def __call__(self, *args, **kwargs): from sentry_sdk.integrations.mcp import MCPIntegration -def get_initialization_payload(request_id: str): - return SessionMessage( - message=JSONRPCMessage( - root=JSONRPCRequest( - jsonrpc="2.0", - id=request_id, - method="initialize", - params={ - "protocolVersion": "2025-11-25", - "capabilities": {}, - "clientInfo": {"name": "test-client", "version": "1.0.0"}, - }, - ) - ) - ) - - -def get_initialized_notification_payload(): - return SessionMessage( - message=JSONRPCMessage( - root=JSONRPCNotification( - jsonrpc="2.0", - method="notifications/initialized", - ) - ) - ) - - -def get_mcp_command_payload(method: str, params, request_id: str): - return SessionMessage( - message=JSONRPCMessage( - root=JSONRPCRequest( - jsonrpc="2.0", - id=request_id, - method=method, - params=params, - ) - ) - ) - - -async def stdio(server, method: str, params, request_id: str): - read_stream_writer, read_stream = anyio.create_memory_object_stream(0) - write_stream, write_stream_reader = anyio.create_memory_object_stream(0) - - result = {} - - async def run_server(): - await server.run( - read_stream, write_stream, server.create_initialization_options() - ) - - async def simulate_client(tg, result): - init_request = get_initialization_payload("1") - await read_stream_writer.send(init_request) - - await write_stream_reader.receive() - - initialized_notification = get_initialized_notification_payload() - await read_stream_writer.send(initialized_notification) - - request = get_mcp_command_payload(method, params=params, request_id=request_id) - await read_stream_writer.send(request) - - result["response"] = await write_stream_reader.receive() - - tg.cancel_scope.cancel() - - async with anyio.create_task_group() as tg: - tg.start_soon(run_server) - tg.start_soon(simulate_client, tg, result) - - return result["response"] - - @pytest.fixture(autouse=True) def reset_request_ctx(): """Reset request context before and after each test""" @@ -234,7 +157,7 @@ def test_integration_patches_server(sentry_init): [(True, True), (True, False), (False, True), (False, False)], ) async def test_tool_handler_stdio( - sentry_init, capture_events, send_default_pii, include_prompts + sentry_init, capture_events, send_default_pii, include_prompts, stdio ): """Test that synchronous tool handlers create proper spans""" sentry_init( @@ -357,7 +280,7 @@ async def test_tool_async(tool_name, arguments): @pytest.mark.asyncio -async def test_tool_handler_with_error(sentry_init, capture_events): +async def test_tool_handler_with_error(sentry_init, capture_events, stdio): """Test that tool handler errors are captured properly""" sentry_init( integrations=[MCPIntegration()], @@ -412,7 +335,7 @@ def failing_tool(tool_name, arguments): [(True, True), (True, False), (False, True), (False, False)], ) async def test_prompt_handler_sync( - sentry_init, capture_events, send_default_pii, include_prompts + sentry_init, capture_events, send_default_pii, include_prompts, stdio ): """Test that synchronous prompt handlers create proper spans""" sentry_init( @@ -539,7 +462,7 @@ async def test_prompt_async(name, arguments): @pytest.mark.asyncio -async def test_prompt_handler_with_error(sentry_init, capture_events): +async def test_prompt_handler_with_error(sentry_init, capture_events, stdio): """Test that prompt handler errors are captured""" sentry_init( integrations=[MCPIntegration()], @@ -575,7 +498,7 @@ async def failing_prompt(name, arguments): @pytest.mark.asyncio -async def test_resource_handler_sync(sentry_init, capture_events): +async def test_resource_handler_sync(sentry_init, capture_events, stdio): """Test that synchronous resource handlers create proper spans""" sentry_init( integrations=[MCPIntegration()], @@ -667,7 +590,7 @@ async def test_resource_async(uri): @pytest.mark.asyncio -async def test_resource_handler_with_error(sentry_init, capture_events): +async def test_resource_handler_with_error(sentry_init, capture_events, stdio): """Test that resource handler errors are captured""" sentry_init( integrations=[MCPIntegration()], @@ -705,7 +628,7 @@ def failing_resource(uri): [(True, True), (False, False)], ) async def test_tool_result_extraction_tuple( - sentry_init, capture_events, send_default_pii, include_prompts + sentry_init, capture_events, send_default_pii, include_prompts, stdio ): """Test extraction of tool results from tuple format (UnstructuredContent, StructuredContent)""" sentry_init( @@ -758,7 +681,7 @@ def test_tool_tuple(tool_name, arguments): [(True, True), (False, False)], ) async def test_tool_result_extraction_unstructured( - sentry_init, capture_events, send_default_pii, include_prompts + sentry_init, capture_events, send_default_pii, include_prompts, stdio ): """Test extraction of tool results from UnstructuredContent (list of content blocks)""" sentry_init( @@ -802,7 +725,7 @@ def test_tool_unstructured(tool_name, arguments): @pytest.mark.asyncio -async def test_span_origin(sentry_init, capture_events): +async def test_span_origin(sentry_init, capture_events, stdio): """Test that span origin is set correctly""" sentry_init( integrations=[MCPIntegration()], @@ -834,7 +757,7 @@ def test_tool(tool_name, arguments): @pytest.mark.asyncio -async def test_multiple_handlers(sentry_init, capture_events): +async def test_multiple_handlers(sentry_init, capture_events, stdio): """Test that multiple handler calls create multiple spans""" sentry_init( integrations=[MCPIntegration()], @@ -914,7 +837,7 @@ def prompt1(name, arguments): [(True, True), (False, False)], ) async def test_prompt_with_dict_result( - sentry_init, capture_events, send_default_pii, include_prompts + sentry_init, capture_events, send_default_pii, include_prompts, stdio ): """Test prompt handler with dict result instead of GetPromptResult object""" sentry_init( @@ -966,7 +889,7 @@ def test_prompt_dict(name, arguments): @pytest.mark.asyncio @pytest.mark.skip -async def test_resource_without_protocol(sentry_init, capture_events): +async def test_resource_without_protocol(sentry_init, capture_events, stdio): """Test resource handler with URI without protocol scheme""" sentry_init( integrations=[MCPIntegration()], @@ -999,7 +922,7 @@ def test_resource(uri): @pytest.mark.asyncio -async def test_tool_with_complex_arguments(sentry_init, capture_events): +async def test_tool_with_complex_arguments(sentry_init, capture_events, stdio): """Test tool handler with complex nested arguments""" sentry_init( integrations=[MCPIntegration()], @@ -1146,7 +1069,7 @@ def test_tool(tool_name, arguments): @pytest.mark.asyncio -async def test_stdio_transport_detection(sentry_init, capture_events): +async def test_stdio_transport_detection(sentry_init, capture_events, stdio): """Test that stdio transport is correctly detected when no HTTP request""" sentry_init( integrations=[MCPIntegration()], From ac8e6e429e86007eb312595a14062312a34366d0 Mon Sep 17 00:00:00 2001 From: Alexander Alderman Webb Date: Tue, 27 Jan 2026 14:07:01 +0100 Subject: [PATCH 06/28] gate import in conftest --- tests/conftest.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 0164a77681..6547251ac0 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,4 +1,3 @@ -import anyio import json import os import socket @@ -668,8 +667,8 @@ def stdio( get_mcp_command_payload, ): async def inner(server, method: str, params, request_id: str): - read_stream_writer, read_stream = anyio.create_memory_object_stream(0) - write_stream, write_stream_reader = anyio.create_memory_object_stream(0) + read_stream_writer, read_stream = create_memory_object_stream(0) # type: ignore + write_stream, write_stream_reader = create_memory_object_stream(0) # type: ignore result = {} @@ -696,7 +695,7 @@ async def simulate_client(tg, result): tg.cancel_scope.cancel() - async with anyio.create_task_group() as tg: + async with create_task_group() as tg: # type: ignore tg.start_soon(run_server) tg.start_soon(simulate_client, tg, result) From 2766b40df928597d07f8e1ceb289e8efb53de72d Mon Sep 17 00:00:00 2001 From: Alexander Alderman Webb Date: Wed, 28 Jan 2026 14:12:18 +0100 Subject: [PATCH 07/28] test(mcp): Use TestClient for Streamable HTTP --- tests/integrations/mcp/test_mcp.py | 329 +++++++++++++++++++++-------- 1 file changed, 242 insertions(+), 87 deletions(-) diff --git a/tests/integrations/mcp/test_mcp.py b/tests/integrations/mcp/test_mcp.py index 7358c3e6b4..f45564d26b 100644 --- a/tests/integrations/mcp/test_mcp.py +++ b/tests/integrations/mcp/test_mcp.py @@ -28,18 +28,15 @@ async def __call__(self, *args, **kwargs): return super(AsyncMock, self).__call__(*args, **kwargs) +from mcp.types import GetPromptResult, PromptMessage, TextContent +from mcp.server.lowlevel.helper_types import ReadResourceContents from mcp.server.lowlevel import Server from mcp.server.lowlevel.server import request_ctx -from mcp.types import ( - JSONRPCMessage, - JSONRPCNotification, - JSONRPCRequest, - GetPromptResult, - PromptMessage, - TextContent, -) -from mcp.server.lowlevel.helper_types import ReadResourceContents -from mcp.shared.message import SessionMessage +from mcp.server.streamable_http_manager import StreamableHTTPSessionManager + +from starlette.routing import Mount +from starlette.applications import Starlette +from starlette.testclient import TestClient try: from mcp.server.lowlevel.server import request_ctx @@ -51,6 +48,71 @@ async def __call__(self, *args, **kwargs): from sentry_sdk.integrations.mcp import MCPIntegration +def json_rpc(app, method: str, params, request_id: str): + with TestClient(app) as client: + init_response = client.post( + "/mcp/", + headers={ + "Accept": "application/json, text/event-stream", + "Content-Type": "application/json", + }, + json={ + "jsonrpc": "2.0", + "method": "initialize", + "params": { + "clientInfo": {"name": "test-client", "version": "1.0"}, + "protocolVersion": "2025-11-25", + "capabilities": {}, + }, + "id": request_id, + }, + ) + + session_id = init_response.headers["mcp-session-id"] + + # Notification response is mandatory. + # https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle + client.post( + "/mcp/", + headers={ + "Accept": "application/json, text/event-stream", + "Content-Type": "application/json", + "mcp-session-id": session_id, + }, + json={ + "jsonrpc": "2.0", + "method": "notifications/initialized", + "params": {}, + }, + ) + + response = client.post( + "/mcp/", + headers={ + "Accept": "application/json, text/event-stream", + "Content-Type": "application/json", + "mcp-session-id": session_id, + }, + json={ + "jsonrpc": "2.0", + "method": method, + "params": params, + "id": request_id, + }, + ) + + return session_id, response + + +def select_mcp_transactions(events): + return [ + event + for event in events + if event["type"] == "transaction" + and event["contexts"]["trace"]["op"] == "mcp.server" + ] + + @pytest.fixture(autouse=True) def reset_request_ctx(): """Reset request context before and after each test""" @@ -238,45 +300,71 @@ async def test_tool_handler_async( server = Server("test-server") - # Set up mock request context - mock_ctx = MockRequestContext( - request_id="req-456", session_id="session-789", transport="http" + session_manager = StreamableHTTPSessionManager( + app=server, + json_response=True, + ) + + app = Starlette( + routes=[ + Mount("/mcp", app=session_manager.handle_request), + ], + lifespan=lambda app: session_manager.run(), ) - request_ctx.set(mock_ctx) @server.call_tool() async def test_tool_async(tool_name, arguments): - return {"status": "completed"} - - with start_transaction(name="mcp tx"): - result = await test_tool_async("process", {"data": "test"}) + return [ + TextContent( + type="text", + text=json.dumps({"status": "completed"}), + ) + ] - assert result == {"status": "completed"} + session_id, result = json_rpc( + app, + method="tools/call", + params={ + "name": "process", + "arguments": { + "data": "test", + }, + }, + request_id="req-456", + ) + assert result.json()["result"]["content"][0]["text"] == json.dumps( + {"status": "completed"} + ) - (tx,) = events + transactions = select_mcp_transactions(events) + assert len(transactions) == 1 + tx = transactions[0] assert tx["type"] == "transaction" - assert len(tx["spans"]) == 1 - span = tx["spans"][0] - assert span["op"] == OP.MCP_SERVER - assert span["description"] == "tools/call process" - assert span["origin"] == "auto.ai.mcp" + assert tx["contexts"]["trace"]["op"] == OP.MCP_SERVER + assert tx["transaction"] == "tools/call process" + assert tx["contexts"]["trace"]["origin"] == "auto.ai.mcp" # Check span data - assert span["data"][SPANDATA.MCP_TOOL_NAME] == "process" - assert span["data"][SPANDATA.MCP_METHOD_NAME] == "tools/call" - assert span["data"][SPANDATA.MCP_TRANSPORT] == "http" - assert span["data"][SPANDATA.MCP_REQUEST_ID] == "req-456" - assert span["data"][SPANDATA.MCP_SESSION_ID] == "session-789" - assert span["data"]["mcp.request.argument.data"] == '"test"' + assert tx["contexts"]["trace"]["data"][SPANDATA.MCP_TOOL_NAME] == "process" + assert tx["contexts"]["trace"]["data"][SPANDATA.MCP_METHOD_NAME] == "tools/call" + assert tx["contexts"]["trace"]["data"][SPANDATA.MCP_TRANSPORT] == "http" + assert tx["contexts"]["trace"]["data"][SPANDATA.MCP_REQUEST_ID] == "req-456" + assert tx["contexts"]["trace"]["data"][SPANDATA.MCP_SESSION_ID] == session_id + assert tx["contexts"]["trace"]["data"]["mcp.request.argument.data"] == '"test"' # Check PII-sensitive data if send_default_pii and include_prompts: - assert span["data"][SPANDATA.MCP_TOOL_RESULT_CONTENT] == json.dumps( - {"status": "completed"} + # TODO: Investigate why tool result is double-serialized. + assert tx["contexts"]["trace"]["data"][ + SPANDATA.MCP_TOOL_RESULT_CONTENT + ] == json.dumps( + json.dumps( + {"status": "completed"}, + ) ) else: - assert SPANDATA.MCP_TOOL_RESULT_CONTENT not in span["data"] + assert SPANDATA.MCP_TOOL_RESULT_CONTENT not in tx["contexts"]["trace"]["data"] @pytest.mark.asyncio @@ -426,39 +514,66 @@ async def test_prompt_handler_async( server = Server("test-server") - # Set up mock request context - mock_ctx = MockRequestContext( - request_id="req-async-prompt", session_id="session-abc", transport="http" + session_manager = StreamableHTTPSessionManager( + app=server, + json_response=True, + ) + + app = Starlette( + routes=[ + Mount("/mcp", app=session_manager.handle_request), + ], + lifespan=lambda app: session_manager.run(), ) - request_ctx.set(mock_ctx) @server.get_prompt() async def test_prompt_async(name, arguments): - return MockGetPromptResult( - [ - MockPromptMessage("system", "You are a helpful assistant"), - MockPromptMessage("user", "What is MCP?"), - ] + return 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?") + ), + ], ) - with start_transaction(name="mcp tx"): - result = await test_prompt_async("mcp_info", {}) - - assert len(result.messages) == 2 + _, result = json_rpc( + app, + method="prompts/get", + params={ + "name": "mcp_info", + "arguments": {}, + }, + request_id="req-async-prompt", + ) + assert len(result.json()["result"]["messages"]) == 2 - (tx,) = events + transactions = select_mcp_transactions(events) + assert len(transactions) == 1 + tx = transactions[0] assert tx["type"] == "transaction" - assert len(tx["spans"]) == 1 - span = tx["spans"][0] - assert span["op"] == OP.MCP_SERVER - assert span["description"] == "prompts/get mcp_info" + assert tx["contexts"]["trace"]["op"] == OP.MCP_SERVER + assert tx["transaction"] == "prompts/get mcp_info" # For multi-message prompts, count is always captured - assert span["data"][SPANDATA.MCP_PROMPT_RESULT_MESSAGE_COUNT] == 2 + assert ( + tx["contexts"]["trace"]["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 span["data"] - assert SPANDATA.MCP_PROMPT_RESULT_MESSAGE_CONTENT not in span["data"] + assert ( + SPANDATA.MCP_PROMPT_RESULT_MESSAGE_ROLE not in tx["contexts"]["trace"]["data"] + ) + assert ( + SPANDATA.MCP_PROMPT_RESULT_MESSAGE_CONTENT + not in tx["contexts"]["trace"]["data"] + ) @pytest.mark.asyncio @@ -560,33 +675,53 @@ async def test_resource_handler_async(sentry_init, capture_events): server = Server("test-server") - # Set up mock request context - mock_ctx = MockRequestContext( - request_id="req-async-resource", session_id="session-res", transport="http" + session_manager = StreamableHTTPSessionManager( + app=server, + json_response=True, + ) + + app = Starlette( + routes=[ + Mount("/mcp", app=session_manager.handle_request), + ], + lifespan=lambda app: session_manager.run(), ) - request_ctx.set(mock_ctx) @server.read_resource() async def test_resource_async(uri): - return {"data": "resource data"} + return [ + ReadResourceContents( + content=json.dumps({"data": "resource data"}), mime_type="text/plain" + ) + ] - with start_transaction(name="mcp tx"): - uri = MockURI("https://example.com/resource") - result = await test_resource_async(uri) + session_id, result = json_rpc( + app, + method="resources/read", + params={ + "uri": "https://example.com/resource", + }, + request_id="req-async-resource", + ) - assert result["data"] == "resource data" + assert result.json()["result"]["contents"][0]["text"] == json.dumps( + {"data": "resource data"} + ) - (tx,) = events + transactions = select_mcp_transactions(events) + assert len(transactions) == 1 + tx = transactions[0] assert tx["type"] == "transaction" - assert len(tx["spans"]) == 1 - span = tx["spans"][0] - assert span["op"] == OP.MCP_SERVER - assert span["description"] == "resources/read https://example.com/resource" + assert tx["contexts"]["trace"]["op"] == OP.MCP_SERVER + assert tx["transaction"] == "resources/read https://example.com/resource" - assert span["data"][SPANDATA.MCP_RESOURCE_URI] == "https://example.com/resource" - assert span["data"][SPANDATA.MCP_RESOURCE_PROTOCOL] == "https" - assert span["data"][SPANDATA.MCP_SESSION_ID] == "session-res" + assert ( + tx["contexts"]["trace"]["data"][SPANDATA.MCP_RESOURCE_URI] + == "https://example.com/resource" + ) + assert tx["contexts"]["trace"]["data"][SPANDATA.MCP_RESOURCE_PROTOCOL] == "https" + assert tx["contexts"]["trace"]["data"][SPANDATA.MCP_SESSION_ID] == session_id @pytest.mark.asyncio @@ -1044,28 +1179,48 @@ def test_streamable_http_transport_detection(sentry_init, capture_events): server = Server("test-server") - # Set up mock request context with StreamableHTTP transport - mock_ctx = MockRequestContext( - request_id="req-http", session_id="session-http-456", transport="http" + session_manager = StreamableHTTPSessionManager( + app=server, + json_response=True, ) - request_ctx.set(mock_ctx) - @server.call_tool() - def test_tool(tool_name, arguments): - return {"result": "success"} + app = Starlette( + routes=[ + Mount("/mcp", app=session_manager.handle_request), + ], + lifespan=lambda app: session_manager.run(), + ) - with start_transaction(name="mcp tx"): - result = test_tool("http_tool", {}) + @server.call_tool() + async def test_tool(tool_name, arguments): + return [ + TextContent( + type="text", + text=json.dumps({"status": "success"}), + ) + ] - assert result == {"result": "success"} + session_id, result = json_rpc( + app, + method="tools/call", + params={ + "name": "http_tool", + "arguments": {}, + }, + request_id="req-http", + ) + assert result.json()["result"]["content"][0]["text"] == json.dumps( + {"status": "success"} + ) - (tx,) = events - span = tx["spans"][0] + transactions = select_mcp_transactions(events) + assert len(transactions) == 1 + tx = transactions[0] # Check that HTTP transport is detected - assert span["data"][SPANDATA.MCP_TRANSPORT] == "http" - assert span["data"][SPANDATA.NETWORK_TRANSPORT] == "tcp" - assert span["data"][SPANDATA.MCP_SESSION_ID] == "session-http-456" + assert tx["contexts"]["trace"]["data"][SPANDATA.MCP_TRANSPORT] == "http" + assert tx["contexts"]["trace"]["data"][SPANDATA.NETWORK_TRANSPORT] == "tcp" + assert tx["contexts"]["trace"]["data"][SPANDATA.MCP_SESSION_ID] == session_id @pytest.mark.asyncio From d3afb383ab802f83711ec45f6fb5f42032982641 Mon Sep 17 00:00:00 2001 From: Alexander Alderman Webb Date: Wed, 28 Jan 2026 14:24:17 +0100 Subject: [PATCH 08/28] test(fastmcp): Use TestClient for Streamable HTTP --- tests/conftest.py | 83 ++++++++ tests/integrations/fastmcp/test_fastmcp.py | 234 +++++++++++++++------ tests/integrations/mcp/test_mcp.py | 85 +++----- 3 files changed, 278 insertions(+), 124 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 6547251ac0..e529c9a316 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -16,6 +16,12 @@ from werkzeug.wrappers import Request, Response import jsonschema +try: + from starlette.testclient import TestClient + # Catch RuntimeError to prevent the following exception in aws_lambda tests. + # RuntimeError: The starlette.testclient module requires the httpx package to be installed. +except (ImportError, RuntimeError): + TestClient = None try: import gevent @@ -701,6 +707,83 @@ async def simulate_client(tg, result): return result["response"] + +@pytest.fixture() +def json_rpc(): + def inner(app, method: str, params, request_id: str | None = None): + if request_id is None: + request_id = "1" # arbitrary + + with TestClient(app) as client: # type: ignore + init_response = client.post( + "/mcp/", + headers={ + "Accept": "application/json, text/event-stream", + "Content-Type": "application/json", + }, + json={ + "jsonrpc": "2.0", + "method": "initialize", + "params": { + "clientInfo": {"name": "test-client", "version": "1.0"}, + "protocolVersion": "2025-11-25", + "capabilities": {}, + }, + "id": request_id, + }, + ) + + session_id = init_response.headers["mcp-session-id"] + + # Notification response is mandatory. + # https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle + client.post( + "/mcp/", + headers={ + "Accept": "application/json, text/event-stream", + "Content-Type": "application/json", + "mcp-session-id": session_id, + }, + json={ + "jsonrpc": "2.0", + "method": "notifications/initialized", + "params": {}, + }, + ) + + response = client.post( + "/mcp/", + headers={ + "Accept": "application/json, text/event-stream", + "Content-Type": "application/json", + "mcp-session-id": session_id, + }, + json={ + "jsonrpc": "2.0", + "method": method, + "params": params, + "id": request_id, + }, + ) + + return session_id, response + + return inner + + +@pytest.fixture() +def select_transactions_with_mcp_spans(): + def inner(events, method_name): + return [ + transaction + for transaction in events + if transaction["type"] == "transaction" + and any( + span["data"].get("mcp.method.name") == method_name + for span in transaction.get("spans", []) + ) + ] + return inner diff --git a/tests/integrations/fastmcp/test_fastmcp.py b/tests/integrations/fastmcp/test_fastmcp.py index 0c82610b4d..14cd6d4d9a 100644 --- a/tests/integrations/fastmcp/test_fastmcp.py +++ b/tests/integrations/fastmcp/test_fastmcp.py @@ -39,6 +39,11 @@ async def __call__(self, *args, **kwargs): from sentry_sdk.consts import SPANDATA, OP from sentry_sdk.integrations.mcp import MCPIntegration +from mcp.server.streamable_http_manager import StreamableHTTPSessionManager + +from starlette.routing import Mount +from starlette.applications import Starlette + # Try to import both FastMCP implementations try: from mcp.server.fastmcp import FastMCP as MCPFastMCP @@ -71,6 +76,10 @@ async def __call__(self, *args, **kwargs): GetPromptRequest = None ReadResourceRequest = None +try: + from fastmcp import __version__ as FASTMCP_VERSION +except ImportError: + FASTMCP_VERSION = None try: from fastmcp import __version__ as FASTMCP_VERSION @@ -367,7 +376,13 @@ def add_numbers(a: int, b: int) -> dict: [(True, True), (True, False), (False, True), (False, False)], ) async def test_fastmcp_tool_async( - sentry_init, capture_events, FastMCP, send_default_pii, include_prompts + sentry_init, + capture_events, + FastMCP, + send_default_pii, + include_prompts, + json_rpc, + select_transactions_with_mcp_spans, ): """Test that FastMCP async tool handlers create proper spans""" sentry_init( @@ -379,28 +394,57 @@ async def test_fastmcp_tool_async( mcp = FastMCP("Test Server") - # Set up mock request context - if request_ctx is not None: - mock_ctx = MockRequestContext( - request_id="req-456", session_id="session-789", transport="http" - ) - request_ctx.set(mock_ctx) + 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"} - with start_transaction(name="fastmcp tx"): - result = await call_tool_through_mcp_async( - mcp, "multiply_numbers", {"x": 7, "y": 6} - ) + session_id, result = json_rpc( + app, + method="tools/call", + params={ + "name": "multiply_numbers", + "arguments": {"x": 7, "y": 6}, + }, + request_id="req-456", + ) - assert result == {"result": 42, "operation": "multiplication"} + if ( + isinstance(mcp, StandaloneFastMCP) + and FASTMCP_VERSION is not None + and FASTMCP_VERSION.startswith("2") + ): + assert result.json()["result"]["structuredContent"] == { + "result": 42, + "operation": "multiplication", + } + elif ( + isinstance(mcp, StandaloneFastMCP) and FASTMCP_VERSION is not None + ): # Checking for None is not precise. + assert result.json()["result"]["content"][0]["text"] == json.dumps( + {"result": 42, "operation": "multiplication"}, + ) + else: + assert result.json()["result"]["content"][0]["text"] == json.dumps( + {"result": 42, "operation": "multiplication"}, + indent=2, + ) - (tx,) = events - assert tx["type"] == "transaction" - assert len(tx["spans"]) == 1 + transactions = select_transactions_with_mcp_spans(events, "tools/call") + assert len(transactions) == 1 + tx = transactions[0] # Verify span structure span = tx["spans"][0] @@ -411,7 +455,7 @@ async def multiply_numbers(x: int, y: int) -> dict: 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-789" + assert span["data"][SPANDATA.MCP_SESSION_ID] == session_id # Check PII-sensitive data if send_default_pii and include_prompts: @@ -710,7 +754,13 @@ def code_help_prompt(language: str): @pytest.mark.parametrize("FastMCP", fastmcp_implementations, ids=fastmcp_ids) @pytest.mark.asyncio -async def test_fastmcp_prompt_async(sentry_init, capture_events, FastMCP): +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()], @@ -720,12 +770,17 @@ async def test_fastmcp_prompt_async(sentry_init, capture_events, FastMCP): mcp = FastMCP("Test Server") - # Set up mock request context - if request_ctx is not None: - mock_ctx = MockRequestContext( - request_id="req-async-prompt", session_id="session-abc", transport="http" - ) - request_ctx.set(mock_ctx) + 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 try: @@ -748,15 +803,19 @@ async def async_prompt(topic: str): }, ] - with start_transaction(name="fastmcp tx"): - result = await call_prompt_through_mcp_async( - mcp, "async_prompt", {"topic": "MCP"} - ) + _, result = json_rpc( + app, + method="prompts/get", + params={ + "name": "async_prompt", + "arguments": {"topic": "MCP"}, + }, + ) - assert len(result.messages) == 2 + assert len(result.json()["result"]["messages"]) == 2 - (tx,) = events - assert tx["type"] == "transaction" + transactions = select_transactions_with_mcp_spans(events, "prompts/get") + assert len(transactions) == 1 except AttributeError: # Prompt handler not supported in this version pytest.skip("Prompt handlers not supported in this FastMCP version") @@ -826,7 +885,13 @@ def read_file(path: str): @pytest.mark.parametrize("FastMCP", fastmcp_implementations, ids=fastmcp_ids) @pytest.mark.asyncio -async def test_fastmcp_resource_async(sentry_init, capture_events, FastMCP): +async def test_fastmcp_resource_async( + sentry_init, + capture_events, + FastMCP, + json_rpc, + select_transactions_with_mcp_spans, +): """Test that FastMCP async resource handlers create proper spans""" sentry_init( integrations=[MCPIntegration()], @@ -836,12 +901,17 @@ async def test_fastmcp_resource_async(sentry_init, capture_events, FastMCP): mcp = FastMCP("Test Server") - # Set up mock request context - if request_ctx is not None: - mock_ctx = MockRequestContext( - request_id="req-async-resource", session_id="session-res", transport="http" - ) - request_ctx.set(mock_ctx) + 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 resource handler try: @@ -852,23 +922,26 @@ async def read_url(resource: str): """Read a URL resource""" return "resource data" - with start_transaction(name="fastmcp tx"): - try: - result = await call_resource_through_mcp_async( - mcp, "https://example.com/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 + _, result = json_rpc( + app, + method="resources/read", + params={ + "uri": "https://example.com/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.contents[0].text + assert "resource data" in result.json()["result"]["contents"][0]["text"] - (tx,) = events - assert tx["type"] == "transaction" + transactions = select_transactions_with_mcp_spans(events, "resources/read") + assert len(transactions) == 1 + tx = transactions[0] # Verify span was created resource_spans = [s for s in tx["spans"] if s["op"] == OP.MCP_SERVER] @@ -996,7 +1069,13 @@ def sse_tool(value: str) -> dict: @pytest.mark.parametrize("FastMCP", fastmcp_implementations, ids=fastmcp_ids) -def test_fastmcp_http_transport(sentry_init, capture_events, FastMCP): +def test_fastmcp_http_transport( + sentry_init, + capture_events, + FastMCP, + json_rpc, + select_transactions_with_mcp_spans, +): """Test that FastMCP correctly detects HTTP transport""" sentry_init( integrations=[MCPIntegration()], @@ -1006,24 +1085,53 @@ def test_fastmcp_http_transport(sentry_init, capture_events, FastMCP): mcp = FastMCP("Test Server") - # Set up mock request context with HTTP transport - if request_ctx is not None: - mock_ctx = MockRequestContext( - request_id="req-http", session_id="session-http-456", transport="http" - ) - request_ctx.set(mock_ctx) + 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() def http_tool(data: str) -> dict: """Tool for HTTP transport test""" return {"processed": data.upper()} - with start_transaction(name="fastmcp tx"): - result = call_tool_through_mcp(mcp, "http_tool", {"data": "test"}) + _, result = json_rpc( + app, + method="tools/call", + params={ + "name": "http_tool", + "arguments": {"data": "test"}, + }, + ) - assert result == {"processed": "TEST"} + if ( + isinstance(mcp, StandaloneFastMCP) + and FASTMCP_VERSION is not None + and FASTMCP_VERSION.startswith("2") + ): + assert result.json()["result"]["structuredContent"] == {"processed": "TEST"} + elif ( + isinstance(mcp, StandaloneFastMCP) and FASTMCP_VERSION is not None + ): # Checking for None is not precise. + assert result.json()["result"]["content"][0]["text"] == json.dumps( + {"processed": "TEST"}, + ) + else: + assert result.json()["result"]["content"][0]["text"] == json.dumps( + {"processed": "TEST"}, + indent=2, + ) - (tx,) = events + transactions = select_transactions_with_mcp_spans(events, "tools/call") + assert len(transactions) == 1 + tx = transactions[0] # Find MCP spans mcp_spans = [s for s in tx["spans"] if s["op"] == OP.MCP_SERVER] diff --git a/tests/integrations/mcp/test_mcp.py b/tests/integrations/mcp/test_mcp.py index f45564d26b..29a7a58d07 100644 --- a/tests/integrations/mcp/test_mcp.py +++ b/tests/integrations/mcp/test_mcp.py @@ -36,7 +36,6 @@ async def __call__(self, *args, **kwargs): from starlette.routing import Mount from starlette.applications import Starlette -from starlette.testclient import TestClient try: from mcp.server.lowlevel.server import request_ctx @@ -48,62 +47,6 @@ async def __call__(self, *args, **kwargs): from sentry_sdk.integrations.mcp import MCPIntegration -def json_rpc(app, method: str, params, request_id: str): - with TestClient(app) as client: - init_response = client.post( - "/mcp/", - headers={ - "Accept": "application/json, text/event-stream", - "Content-Type": "application/json", - }, - json={ - "jsonrpc": "2.0", - "method": "initialize", - "params": { - "clientInfo": {"name": "test-client", "version": "1.0"}, - "protocolVersion": "2025-11-25", - "capabilities": {}, - }, - "id": request_id, - }, - ) - - session_id = init_response.headers["mcp-session-id"] - - # Notification response is mandatory. - # https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle - client.post( - "/mcp/", - headers={ - "Accept": "application/json, text/event-stream", - "Content-Type": "application/json", - "mcp-session-id": session_id, - }, - json={ - "jsonrpc": "2.0", - "method": "notifications/initialized", - "params": {}, - }, - ) - - response = client.post( - "/mcp/", - headers={ - "Accept": "application/json, text/event-stream", - "Content-Type": "application/json", - "mcp-session-id": session_id, - }, - json={ - "jsonrpc": "2.0", - "method": method, - "params": params, - "id": request_id, - }, - ) - - return session_id, response - - def select_mcp_transactions(events): return [ event @@ -288,7 +231,12 @@ async def test_tool(tool_name, arguments): [(True, True), (True, False), (False, True), (False, False)], ) async def test_tool_handler_async( - sentry_init, capture_events, send_default_pii, include_prompts + sentry_init, + capture_events, + send_default_pii, + include_prompts, + json_rpc, + select_transactions_with_mcp_spans, ): """Test that async tool handlers create proper spans""" sentry_init( @@ -502,7 +450,12 @@ async def test_prompt(name, arguments): [(True, True), (True, False), (False, True), (False, False)], ) async def test_prompt_handler_async( - sentry_init, capture_events, send_default_pii, include_prompts + sentry_init, + capture_events, + send_default_pii, + include_prompts, + json_rpc, + select_transactions_with_mcp_spans, ): """Test that async prompt handlers create proper spans""" sentry_init( @@ -665,7 +618,12 @@ async def test_resource(uri): @pytest.mark.asyncio -async def test_resource_handler_async(sentry_init, capture_events): +async def test_resource_handler_async( + sentry_init, + capture_events, + json_rpc, + select_transactions_with_mcp_spans, +): """Test that async resource handlers create proper spans""" sentry_init( integrations=[MCPIntegration()], @@ -1169,7 +1127,12 @@ def test_tool(tool_name, arguments): assert span["data"][SPANDATA.MCP_SESSION_ID] == "session-sse-123" -def test_streamable_http_transport_detection(sentry_init, capture_events): +def test_streamable_http_transport_detection( + sentry_init, + capture_events, + json_rpc, + select_transactions_with_mcp_spans, +): """Test that StreamableHTTP transport is correctly detected via header""" sentry_init( integrations=[MCPIntegration()], From 270636de48e78e12d37899cfad2e293895515cf5 Mon Sep 17 00:00:00 2001 From: Alexander Alderman Webb Date: Wed, 28 Jan 2026 14:43:27 +0100 Subject: [PATCH 09/28] clean up merge --- tests/conftest.py | 19 +++++---- tests/integrations/fastmcp/test_fastmcp.py | 46 ++++++++++------------ tests/integrations/mcp/test_mcp.py | 17 ++------ 3 files changed, 34 insertions(+), 48 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index e529c9a316..2c8aa7bdf8 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -707,10 +707,12 @@ async def simulate_client(tg, result): return result["response"] + return inner + @pytest.fixture() def json_rpc(): - def inner(app, method: str, params, request_id: str | None = None): + def inner(app, method: str, params, request_id: str): if request_id is None: request_id = "1" # arbitrary @@ -772,16 +774,13 @@ def inner(app, method: str, params, request_id: str | None = None): @pytest.fixture() -def select_transactions_with_mcp_spans(): - def inner(events, method_name): +def select_mcp_transactions(): + def inner(events): return [ - transaction - for transaction in events - if transaction["type"] == "transaction" - and any( - span["data"].get("mcp.method.name") == method_name - for span in transaction.get("spans", []) - ) + event + for event in events + if event["type"] == "transaction" + and event["contexts"]["trace"]["op"] == "mcp.server" ] return inner diff --git a/tests/integrations/fastmcp/test_fastmcp.py b/tests/integrations/fastmcp/test_fastmcp.py index 14cd6d4d9a..f2619b5104 100644 --- a/tests/integrations/fastmcp/test_fastmcp.py +++ b/tests/integrations/fastmcp/test_fastmcp.py @@ -382,7 +382,7 @@ async def test_fastmcp_tool_async( send_default_pii, include_prompts, json_rpc, - select_transactions_with_mcp_spans, + select_mcp_transactions, ): """Test that FastMCP async tool handlers create proper spans""" sentry_init( @@ -442,26 +442,24 @@ async def multiply_numbers(x: int, y: int) -> dict: indent=2, ) - transactions = select_transactions_with_mcp_spans(events, "tools/call") + transactions = select_mcp_transactions(events) assert len(transactions) == 1 tx = transactions[0] - # Verify span structure - 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 + assert tx["contexts"]["trace"]["op"] == OP.MCP_SERVER + assert tx["contexts"]["trace"]["origin"] == "auto.ai.mcp" + assert tx["transaction"] == "tools/call multiply_numbers" + assert tx["contexts"]["trace"]["data"][SPANDATA.MCP_TOOL_NAME] == "multiply_numbers" + assert tx["contexts"]["trace"]["data"][SPANDATA.MCP_METHOD_NAME] == "tools/call" + assert tx["contexts"]["trace"]["data"][SPANDATA.MCP_TRANSPORT] == "http" + assert tx["contexts"]["trace"]["data"][SPANDATA.MCP_REQUEST_ID] == "req-456" + assert tx["contexts"]["trace"]["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"] + assert SPANDATA.MCP_TOOL_RESULT_CONTENT in tx["contexts"]["trace"]["data"] else: - assert SPANDATA.MCP_TOOL_RESULT_CONTENT not in span["data"] + assert SPANDATA.MCP_TOOL_RESULT_CONTENT not in tx["contexts"]["trace"]["data"] @pytest.mark.asyncio @@ -759,7 +757,7 @@ async def test_fastmcp_prompt_async( capture_events, FastMCP, json_rpc, - select_transactions_with_mcp_spans, + select_mcp_transactions, ): """Test that FastMCP async prompt handlers create proper spans""" sentry_init( @@ -810,11 +808,12 @@ async def async_prompt(topic: str): "name": "async_prompt", "arguments": {"topic": "MCP"}, }, + request_id="req-async-prompt", ) assert len(result.json()["result"]["messages"]) == 2 - transactions = select_transactions_with_mcp_spans(events, "prompts/get") + transactions = select_mcp_transactions(events) assert len(transactions) == 1 except AttributeError: # Prompt handler not supported in this version @@ -890,7 +889,7 @@ async def test_fastmcp_resource_async( capture_events, FastMCP, json_rpc, - select_transactions_with_mcp_spans, + select_mcp_transactions, ): """Test that FastMCP async resource handlers create proper spans""" sentry_init( @@ -939,7 +938,7 @@ async def read_url(resource: str): assert "resource data" in result.json()["result"]["contents"][0]["text"] - transactions = select_transactions_with_mcp_spans(events, "resources/read") + transactions = select_mcp_transactions(events, "resources/read") assert len(transactions) == 1 tx = transactions[0] @@ -1074,7 +1073,7 @@ def test_fastmcp_http_transport( capture_events, FastMCP, json_rpc, - select_transactions_with_mcp_spans, + select_mcp_transactions, ): """Test that FastMCP correctly detects HTTP transport""" sentry_init( @@ -1109,6 +1108,7 @@ def http_tool(data: str) -> dict: "name": "http_tool", "arguments": {"data": "test"}, }, + request_id="req-http", ) if ( @@ -1129,16 +1129,12 @@ def http_tool(data: str) -> dict: indent=2, ) - transactions = select_transactions_with_mcp_spans(events, "tools/call") + transactions = select_mcp_transactions(events) assert len(transactions) == 1 tx = transactions[0] - # Find MCP spans - mcp_spans = [s for s in tx["spans"] if s["op"] == OP.MCP_SERVER] - assert len(mcp_spans) >= 1 - span = mcp_spans[0] # Check that HTTP transport is detected - assert span["data"].get(SPANDATA.MCP_TRANSPORT) == "http" + assert tx["contexts"]["trace"]["data"].get(SPANDATA.MCP_TRANSPORT) == "http" @pytest.mark.asyncio diff --git a/tests/integrations/mcp/test_mcp.py b/tests/integrations/mcp/test_mcp.py index 29a7a58d07..798953bda1 100644 --- a/tests/integrations/mcp/test_mcp.py +++ b/tests/integrations/mcp/test_mcp.py @@ -47,15 +47,6 @@ async def __call__(self, *args, **kwargs): from sentry_sdk.integrations.mcp import MCPIntegration -def select_mcp_transactions(events): - return [ - event - for event in events - if event["type"] == "transaction" - and event["contexts"]["trace"]["op"] == "mcp.server" - ] - - @pytest.fixture(autouse=True) def reset_request_ctx(): """Reset request context before and after each test""" @@ -236,7 +227,7 @@ async def test_tool_handler_async( send_default_pii, include_prompts, json_rpc, - select_transactions_with_mcp_spans, + select_mcp_transactions, ): """Test that async tool handlers create proper spans""" sentry_init( @@ -455,7 +446,7 @@ async def test_prompt_handler_async( send_default_pii, include_prompts, json_rpc, - select_transactions_with_mcp_spans, + select_mcp_transactions, ): """Test that async prompt handlers create proper spans""" sentry_init( @@ -622,7 +613,7 @@ async def test_resource_handler_async( sentry_init, capture_events, json_rpc, - select_transactions_with_mcp_spans, + select_mcp_transactions, ): """Test that async resource handlers create proper spans""" sentry_init( @@ -1131,7 +1122,7 @@ def test_streamable_http_transport_detection( sentry_init, capture_events, json_rpc, - select_transactions_with_mcp_spans, + select_mcp_transactions, ): """Test that StreamableHTTP transport is correctly detected via header""" sentry_init( From 960d76cda16734ceee6875db8f6995a145a150c3 Mon Sep 17 00:00:00 2001 From: Alexander Alderman Webb Date: Fri, 30 Jan 2026 11:54:04 +0100 Subject: [PATCH 10/28] test(mcp): Use AsyncClient for SSE --- .../mcp/streaming_asgi_transport.py | 85 +++++++++ tests/integrations/mcp/test_mcp.py | 164 ++++++++++++++++-- 2 files changed, 237 insertions(+), 12 deletions(-) create mode 100644 tests/integrations/mcp/streaming_asgi_transport.py diff --git a/tests/integrations/mcp/streaming_asgi_transport.py b/tests/integrations/mcp/streaming_asgi_transport.py new file mode 100644 index 0000000000..03f84b0e91 --- /dev/null +++ b/tests/integrations/mcp/streaming_asgi_transport.py @@ -0,0 +1,85 @@ +import asyncio +from httpx import ASGITransport, Request, Response, AsyncByteStream +import anyio + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from typing import Any, Callable, MutableMapping + + +class StreamingASGITransport(ASGITransport): + """ + Simple transport whose only purpose is to keep GET request alive in SSE connections, allowing + tests involving SSE interactions to run in-process. + """ + + def __init__( + self, + app: "Callable", + keep_sse_alive: "asyncio.Event", + ) -> None: + self.keep_sse_alive = keep_sse_alive + super().__init__(app) + + async def handle_async_request(self, request: "Request") -> "Response": + scope = { + "type": "http", + "method": request.method, + "headers": [(k.lower(), v) for (k, v) in request.headers.raw], + "path": request.url.path, + "query_string": request.url.query, + } + + is_streaming_sse = scope["method"] == "GET" and scope["path"] == "/sse" + if not is_streaming_sse: + return await super().handle_async_request(request) + + request_body = b"" + if request.content: + request_body = await request.aread() + + body_sender, body_receiver = anyio.create_memory_object_stream[bytes](0) + + async def receive() -> "dict[str, Any]": + if self.keep_sse_alive.is_set(): + return {"type": "http.disconnect"} + + await self.keep_sse_alive.wait() # Keep alive :) + return {"type": "http.request", "body": request_body, "more_body": False} + + async def send(message: "MutableMapping[str, Any]") -> None: + if message["type"] == "http.response.body": + body = message.get("body", b"") + more_body = message.get("more_body", False) + + if body == b"" and not more_body: + return + + if body: + await body_sender.send(body) + + if not more_body: + await body_sender.aclose() + + async def run_app(): + await self.app(scope, receive, send) + + class StreamingBodyStream(AsyncByteStream): + def __init__(self, receiver, task): + self.receiver = receiver + self.task = task + + async def __aiter__(self): + try: + async for chunk in self.receiver: + yield chunk + except anyio.EndOfStream: + pass + finally: + await self.task + + stream = StreamingBodyStream(body_receiver, asyncio.create_task(run_app())) + response = Response(status_code=200, headers=[], stream=stream) + + return response diff --git a/tests/integrations/mcp/test_mcp.py b/tests/integrations/mcp/test_mcp.py index 798953bda1..ab3c2cf73d 100644 --- a/tests/integrations/mcp/test_mcp.py +++ b/tests/integrations/mcp/test_mcp.py @@ -15,6 +15,14 @@ that the integration properly instruments MCP handlers with Sentry spans. """ +import sentry_sdk + +from urllib.parse import urlparse, parse_qs +import anyio +import asyncio +import httpx +from .streaming_asgi_transport import StreamingASGITransport + import pytest import json from unittest import mock @@ -32,9 +40,10 @@ async def __call__(self, *args, **kwargs): from mcp.server.lowlevel.helper_types import ReadResourceContents from mcp.server.lowlevel import Server from mcp.server.lowlevel.server import request_ctx +from mcp.server.sse import SseServerTransport from mcp.server.streamable_http_manager import StreamableHTTPSessionManager -from starlette.routing import Mount +from starlette.routing import Mount, Route, Response from starlette.applications import Starlette try: @@ -129,6 +138,98 @@ def __init__(self, messages): self.messages = messages +async def json_rpc_sse( + app, method: str, params, request_id: str, keep_sse_alive: "asyncio.Event" +): + context = {} + + stream_complete = asyncio.Event() + endpoint_parsed = asyncio.Event() + + # https://github.com/Kludex/starlette/issues/104#issuecomment-729087925 + async with httpx.AsyncClient( + transport=StreamingASGITransport(app=app, keep_sse_alive=keep_sse_alive), + base_url="http://test", + ) as client: + + async def parse_stream(): + async with client.stream("GET", "/sse") as stream: + # Read directly from stream.stream instead of aiter_bytes() + async for chunk in stream.stream: + if b"event: endpoint" in chunk: + sse_text = chunk.decode("utf-8") + url = sse_text.split("data: ")[1] + + parsed = urlparse(url) + query_params = parse_qs(parsed.query) + context["session_id"] = query_params["session_id"][0] + endpoint_parsed.set() + continue + + if b"event: message" in chunk and b"structuredContent" in chunk: + sse_text = chunk.decode("utf-8") + + json_str = sse_text.split("data: ")[1] + context["response"] = json.loads(json_str) + break + + stream_complete.set() + + task = asyncio.create_task(parse_stream()) + await endpoint_parsed.wait() + + await client.post( + f"/messages/?session_id={context['session_id']}", + headers={ + "Content-Type": "application/json", + }, + json={ + "jsonrpc": "2.0", + "method": "initialize", + "params": { + "clientInfo": {"name": "test-client", "version": "1.0"}, + "protocolVersion": "2025-11-25", + "capabilities": {}, + }, + "id": request_id, + }, + ) + + # Notification response is mandatory. + # https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle + await client.post( + f"/messages/?session_id={context['session_id']}", + headers={ + "Content-Type": "application/json", + "mcp-session-id": context["session_id"], + }, + json={ + "jsonrpc": "2.0", + "method": "notifications/initialized", + "params": {}, + }, + ) + + await client.post( + f"/messages/?session_id={context['session_id']}", + headers={ + "Content-Type": "application/json", + "mcp-session-id": context["session_id"], + }, + json={ + "jsonrpc": "2.0", + "method": method, + "params": params, + "id": request_id, + }, + ) + + await stream_complete.wait() + keep_sse_alive.set() + + return task, context["session_id"], context["response"] + + def test_integration_patches_server(sentry_init): """Test that MCPIntegration patches the Server class""" # Get original methods before integration @@ -1084,7 +1185,8 @@ async def async_tool(tool_name, arguments): assert all(span["op"] == OP.MCP_SERVER for span in tx["spans"]) -def test_sse_transport_detection(sentry_init, capture_events): +@pytest.mark.asyncio +async def test_sse_transport_detection(sentry_init, capture_events): """Test that SSE transport is correctly detected via query parameter""" sentry_init( integrations=[MCPIntegration()], @@ -1093,29 +1195,67 @@ def test_sse_transport_detection(sentry_init, capture_events): events = capture_events() server = Server("test-server") + sse = SseServerTransport("/messages/") + + sse_connection_closed = asyncio.Event() + + async def handle_sse(request): + async with sse.connect_sse( + request.scope, request.receive, request._send + ) as streams: + async with anyio.create_task_group() as tg: - # Set up mock request context with SSE transport - mock_ctx = MockRequestContext( - request_id="req-sse", session_id="session-sse-123", transport="sse" + async def run_server(): + await server.run( + streams[0], streams[1], server.create_initialization_options() + ) + + tg.start_soon(run_server) + + sse_connection_closed.set() + return Response() + + app = Starlette( + routes=[ + Route("/sse", endpoint=handle_sse, methods=["GET"]), + Mount("/messages/", app=sse.handle_post_message), + ], ) - request_ctx.set(mock_ctx) @server.call_tool() - def test_tool(tool_name, arguments): + async def test_tool(tool_name, arguments): return {"result": "success"} - with start_transaction(name="mcp tx"): - result = test_tool("sse_tool", {}) + keep_sse_alive = asyncio.Event() + app_task, session_id, result = await json_rpc_sse( + app, + method="tools/call", + params={ + "name": "sse_tool", + "arguments": {}, + }, + request_id="req-sse", + keep_sse_alive=keep_sse_alive, + ) - assert result == {"result": "success"} + await sse_connection_closed.wait() + await app_task - (tx,) = events + assert result["result"]["structuredContent"] == {"result": "success"} + + transactions = [ + event + for event in events + if event["type"] == "transaction" and event["transaction"] == "/sse" + ] + assert len(transactions) == 1 + tx = transactions[0] span = tx["spans"][0] # Check that SSE transport is detected assert span["data"][SPANDATA.MCP_TRANSPORT] == "sse" assert span["data"][SPANDATA.NETWORK_TRANSPORT] == "tcp" - assert span["data"][SPANDATA.MCP_SESSION_ID] == "session-sse-123" + assert span["data"][SPANDATA.MCP_SESSION_ID] == session_id def test_streamable_http_transport_detection( From 2aecd41199e3dbeb8a22427ff03197873499f22a Mon Sep 17 00:00:00 2001 From: Alexander Alderman Webb Date: Fri, 30 Jan 2026 13:29:20 +0100 Subject: [PATCH 11/28] test(fastmcp): Use AsyncClient for SSE --- tests/conftest.py | 201 +++++++++++++++++- tests/integrations/fastmcp/test_fastmcp.py | 87 ++++++-- .../mcp/streaming_asgi_transport.py | 85 -------- tests/integrations/mcp/test_mcp.py | 98 +-------- 4 files changed, 275 insertions(+), 196 deletions(-) delete mode 100644 tests/integrations/mcp/streaming_asgi_transport.py diff --git a/tests/conftest.py b/tests/conftest.py index 2c8aa7bdf8..e581c38d5c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,5 +1,7 @@ import json import os +import asyncio +from urllib.parse import urlparse, parse_qs import socket import warnings import brotli @@ -51,24 +53,33 @@ from typing import TYPE_CHECKING if TYPE_CHECKING: - from typing import Optional + from typing import Any, Callable, MutableMapping, Optional from collections.abc import Iterator try: - from anyio import create_memory_object_stream, create_task_group + from anyio import create_memory_object_stream, create_task_group, EndOfStream from mcp.types import ( JSONRPCMessage, JSONRPCNotification, JSONRPCRequest, ) from mcp.shared.message import SessionMessage + from httpx import ASGITransport, Request, Response, AsyncByteStream, AsyncClient except ImportError: create_memory_object_stream = None create_task_group = None + EndOfStream = None + JSONRPCMessage = None JSONRPCRequest = None SessionMessage = None + ASGITransport = None + Request = None + Response = None + AsyncByteStream = None + AsyncClient = None + SENTRY_EVENT_SCHEMA = "./checkouts/data-schemas/relay/event.schema.json" @@ -786,6 +797,192 @@ def inner(events): return inner +@pytest.fixture() +def json_rpc_sse(is_structured_content: bool = True): + class StreamingASGITransport(ASGITransport): + """ + Simple transport whose only purpose is to keep GET request alive in SSE connections, allowing + tests involving SSE interactions to run in-process. + """ + + def __init__( + self, + app: "Callable", + keep_sse_alive: "asyncio.Event", + ) -> None: + self.keep_sse_alive = keep_sse_alive + super().__init__(app) + + async def handle_async_request(self, request: "Request") -> "Response": + scope = { + "type": "http", + "method": request.method, + "headers": [(k.lower(), v) for (k, v) in request.headers.raw], + "path": request.url.path, + "query_string": request.url.query, + } + + is_streaming_sse = scope["method"] == "GET" and scope["path"] == "/sse" + if not is_streaming_sse: + return await super().handle_async_request(request) + + request_body = b"" + if request.content: + request_body = await request.aread() + + body_sender, body_receiver = create_memory_object_stream[bytes](0) # type: ignore + + async def receive() -> "dict[str, Any]": + if self.keep_sse_alive.is_set(): + return {"type": "http.disconnect"} + + await self.keep_sse_alive.wait() # Keep alive :) + return { + "type": "http.request", + "body": request_body, + "more_body": False, + } + + async def send(message: "MutableMapping[str, Any]") -> None: + if message["type"] == "http.response.body": + body = message.get("body", b"") + more_body = message.get("more_body", False) + + if body == b"" and not more_body: + return + + if body: + await body_sender.send(body) + + if not more_body: + await body_sender.aclose() + + async def run_app(): + await self.app(scope, receive, send) + + class StreamingBodyStream(AsyncByteStream): # type: ignore + def __init__(self, receiver, task): + self.receiver = receiver + self.task = task + + async def __aiter__(self): + try: + async for chunk in self.receiver: + yield chunk + except EndOfStream: # type: ignore + pass + + stream = StreamingBodyStream(body_receiver, asyncio.create_task(run_app())) + response = Response(status_code=200, headers=[], stream=stream) # type: ignore + + return response + + def parse_sse_data_package(sse_chunk): + sse_text = sse_chunk.decode("utf-8") + json_str = sse_text.split("data: ")[1] + return json.loads(json_str) + + async def inner( + app, method: str, params, request_id: str, keep_sse_alive: "asyncio.Event" + ): + context = {} + + stream_complete = asyncio.Event() + endpoint_parsed = asyncio.Event() + + # https://github.com/Kludex/starlette/issues/104#issuecomment-729087925 + async with AsyncClient( # type: ignore + transport=StreamingASGITransport(app=app, keep_sse_alive=keep_sse_alive), + base_url="http://test", + ) as client: + + async def parse_stream(): + async with client.stream("GET", "/sse") as stream: + # Read directly from stream.stream instead of aiter_bytes() + async for chunk in stream.stream: + if b"event: endpoint" in chunk: + sse_text = chunk.decode("utf-8") + url = sse_text.split("data: ")[1] + + parsed = urlparse(url) + query_params = parse_qs(parsed.query) + context["session_id"] = query_params["session_id"][0] + endpoint_parsed.set() + continue + + if ( + is_structured_content + and b"event: message" in chunk + and b"structuredContent" in chunk + ): + context["response"] = parse_sse_data_package(chunk) + stream_complete.set() + break + elif ( + "result" in parse_sse_data_package(chunk) + and "content" in parse_sse_data_package(chunk)["result"] + ): + context["response"] = parse_sse_data_package(chunk) + stream_complete.set() + break + + task = asyncio.create_task(parse_stream()) + await endpoint_parsed.wait() + + await client.post( + f"/messages/?session_id={context['session_id']}", + headers={ + "Content-Type": "application/json", + }, + json={ + "jsonrpc": "2.0", + "method": "initialize", + "params": { + "clientInfo": {"name": "test-client", "version": "1.0"}, + "protocolVersion": "2025-11-25", + "capabilities": {}, + }, + "id": request_id, + }, + ) + + # Notification response is mandatory. + # https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle + await client.post( + f"/messages/?session_id={context['session_id']}", + headers={ + "Content-Type": "application/json", + "mcp-session-id": context["session_id"], + }, + json={ + "jsonrpc": "2.0", + "method": "notifications/initialized", + "params": {}, + }, + ) + + await client.post( + f"/messages/?session_id={context['session_id']}", + headers={ + "Content-Type": "application/json", + "mcp-session-id": context["session_id"], + }, + json={ + "jsonrpc": "2.0", + "method": method, + "params": params, + "id": request_id, + }, + ) + + await stream_complete.wait() + keep_sse_alive.set() + + return task, context["session_id"], context["response"] + + return inner + + class MockServerRequestHandler(BaseHTTPRequestHandler): def do_GET(self): # noqa: N802 # Process an HTTP GET request and return a response. diff --git a/tests/integrations/fastmcp/test_fastmcp.py b/tests/integrations/fastmcp/test_fastmcp.py index f2619b5104..ead395e6c0 100644 --- a/tests/integrations/fastmcp/test_fastmcp.py +++ b/tests/integrations/fastmcp/test_fastmcp.py @@ -21,6 +21,7 @@ accurate testing of the integration's behavior in real MCP Server scenarios. """ +import anyio import asyncio import json import pytest @@ -39,9 +40,12 @@ async def __call__(self, *args, **kwargs): from sentry_sdk.consts import SPANDATA, OP from sentry_sdk.integrations.mcp import MCPIntegration +from mcp.server.lowlevel import Server +from mcp.server.sse import SseServerTransport from mcp.server.streamable_http_manager import StreamableHTTPSessionManager -from starlette.routing import Mount +from starlette.routing import Mount, Route +from starlette.responses import Response from starlette.applications import Starlette # Try to import both FastMCP implementations @@ -1029,8 +1033,11 @@ def test_tool_no_ctx(x: int) -> dict: # ============================================================================= +@pytest.mark.asyncio @pytest.mark.parametrize("FastMCP", fastmcp_implementations, ids=fastmcp_ids) -def test_fastmcp_sse_transport(sentry_init, capture_events, FastMCP): +async def test_fastmcp_sse_transport( + sentry_init, capture_events, FastMCP, json_rpc_sse +): """Test that FastMCP correctly detects SSE transport""" sentry_init( integrations=[MCPIntegration()], @@ -1039,25 +1046,81 @@ def test_fastmcp_sse_transport(sentry_init, capture_events, FastMCP): events = capture_events() mcp = FastMCP("Test Server") + sse = SseServerTransport("/messages/") - # Set up mock request context with SSE transport - if request_ctx is not None: - mock_ctx = MockRequestContext( - request_id="req-sse", session_id="session-sse-123", transport="sse" - ) - request_ctx.set(mock_ctx) + sse_connection_closed = asyncio.Event() + + async def handle_sse(request): + async with sse.connect_sse( + request.scope, request.receive, request._send + ) as streams: + async with anyio.create_task_group() as tg: + + async def run_server(): + await mcp._mcp_server.run( + streams[0], + streams[1], + mcp._mcp_server.create_initialization_options(), + ) + + tg.start_soon(run_server) + + sse_connection_closed.set() + return Response() + + app = Starlette( + routes=[ + Route("/sse", endpoint=handle_sse, methods=["GET"]), + Mount("/messages/", app=sse.handle_post_message), + ], + ) @mcp.tool() def sse_tool(value: str) -> dict: """Tool for SSE transport test""" return {"message": f"Received: {value}"} - with start_transaction(name="fastmcp tx"): - result = call_tool_through_mcp(mcp, "sse_tool", {"value": "hello"}) + keep_sse_alive = asyncio.Event() + 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, + ) - assert result == {"message": "Received: hello"} + await sse_connection_closed.wait() + await app_task - (tx,) = events + if ( + isinstance(mcp, StandaloneFastMCP) + and FASTMCP_VERSION is not None + and FASTMCP_VERSION.startswith("2") + ): + assert result["result"]["content"][0]["text"] == json.dumps( + {"message": "Received: hello"}, separators=(",", ":") + ) + elif ( + isinstance(mcp, StandaloneFastMCP) and FASTMCP_VERSION is not None + ): # Checking for None is not precise. + assert result["result"]["content"][0]["text"] == json.dumps( + {"message": "Received: hello"} + ) + else: + assert result["result"]["content"][0]["text"] == json.dumps( + {"message": "Received: hello"}, indent=2 + ) + + transactions = [ + event + for event in events + if event["type"] == "transaction" and event["transaction"] == "/sse" + ] + assert len(transactions) == 1 + tx = transactions[0] # Find MCP spans mcp_spans = [s for s in tx["spans"] if s["op"] == OP.MCP_SERVER] diff --git a/tests/integrations/mcp/streaming_asgi_transport.py b/tests/integrations/mcp/streaming_asgi_transport.py deleted file mode 100644 index 03f84b0e91..0000000000 --- a/tests/integrations/mcp/streaming_asgi_transport.py +++ /dev/null @@ -1,85 +0,0 @@ -import asyncio -from httpx import ASGITransport, Request, Response, AsyncByteStream -import anyio - -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from typing import Any, Callable, MutableMapping - - -class StreamingASGITransport(ASGITransport): - """ - Simple transport whose only purpose is to keep GET request alive in SSE connections, allowing - tests involving SSE interactions to run in-process. - """ - - def __init__( - self, - app: "Callable", - keep_sse_alive: "asyncio.Event", - ) -> None: - self.keep_sse_alive = keep_sse_alive - super().__init__(app) - - async def handle_async_request(self, request: "Request") -> "Response": - scope = { - "type": "http", - "method": request.method, - "headers": [(k.lower(), v) for (k, v) in request.headers.raw], - "path": request.url.path, - "query_string": request.url.query, - } - - is_streaming_sse = scope["method"] == "GET" and scope["path"] == "/sse" - if not is_streaming_sse: - return await super().handle_async_request(request) - - request_body = b"" - if request.content: - request_body = await request.aread() - - body_sender, body_receiver = anyio.create_memory_object_stream[bytes](0) - - async def receive() -> "dict[str, Any]": - if self.keep_sse_alive.is_set(): - return {"type": "http.disconnect"} - - await self.keep_sse_alive.wait() # Keep alive :) - return {"type": "http.request", "body": request_body, "more_body": False} - - async def send(message: "MutableMapping[str, Any]") -> None: - if message["type"] == "http.response.body": - body = message.get("body", b"") - more_body = message.get("more_body", False) - - if body == b"" and not more_body: - return - - if body: - await body_sender.send(body) - - if not more_body: - await body_sender.aclose() - - async def run_app(): - await self.app(scope, receive, send) - - class StreamingBodyStream(AsyncByteStream): - def __init__(self, receiver, task): - self.receiver = receiver - self.task = task - - async def __aiter__(self): - try: - async for chunk in self.receiver: - yield chunk - except anyio.EndOfStream: - pass - finally: - await self.task - - stream = StreamingBodyStream(body_receiver, asyncio.create_task(run_app())) - response = Response(status_code=200, headers=[], stream=stream) - - return response diff --git a/tests/integrations/mcp/test_mcp.py b/tests/integrations/mcp/test_mcp.py index ab3c2cf73d..4e83c7939c 100644 --- a/tests/integrations/mcp/test_mcp.py +++ b/tests/integrations/mcp/test_mcp.py @@ -16,12 +16,8 @@ """ import sentry_sdk - -from urllib.parse import urlparse, parse_qs import anyio import asyncio -import httpx -from .streaming_asgi_transport import StreamingASGITransport import pytest import json @@ -138,98 +134,6 @@ def __init__(self, messages): self.messages = messages -async def json_rpc_sse( - app, method: str, params, request_id: str, keep_sse_alive: "asyncio.Event" -): - context = {} - - stream_complete = asyncio.Event() - endpoint_parsed = asyncio.Event() - - # https://github.com/Kludex/starlette/issues/104#issuecomment-729087925 - async with httpx.AsyncClient( - transport=StreamingASGITransport(app=app, keep_sse_alive=keep_sse_alive), - base_url="http://test", - ) as client: - - async def parse_stream(): - async with client.stream("GET", "/sse") as stream: - # Read directly from stream.stream instead of aiter_bytes() - async for chunk in stream.stream: - if b"event: endpoint" in chunk: - sse_text = chunk.decode("utf-8") - url = sse_text.split("data: ")[1] - - parsed = urlparse(url) - query_params = parse_qs(parsed.query) - context["session_id"] = query_params["session_id"][0] - endpoint_parsed.set() - continue - - if b"event: message" in chunk and b"structuredContent" in chunk: - sse_text = chunk.decode("utf-8") - - json_str = sse_text.split("data: ")[1] - context["response"] = json.loads(json_str) - break - - stream_complete.set() - - task = asyncio.create_task(parse_stream()) - await endpoint_parsed.wait() - - await client.post( - f"/messages/?session_id={context['session_id']}", - headers={ - "Content-Type": "application/json", - }, - json={ - "jsonrpc": "2.0", - "method": "initialize", - "params": { - "clientInfo": {"name": "test-client", "version": "1.0"}, - "protocolVersion": "2025-11-25", - "capabilities": {}, - }, - "id": request_id, - }, - ) - - # Notification response is mandatory. - # https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle - await client.post( - f"/messages/?session_id={context['session_id']}", - headers={ - "Content-Type": "application/json", - "mcp-session-id": context["session_id"], - }, - json={ - "jsonrpc": "2.0", - "method": "notifications/initialized", - "params": {}, - }, - ) - - await client.post( - f"/messages/?session_id={context['session_id']}", - headers={ - "Content-Type": "application/json", - "mcp-session-id": context["session_id"], - }, - json={ - "jsonrpc": "2.0", - "method": method, - "params": params, - "id": request_id, - }, - ) - - await stream_complete.wait() - keep_sse_alive.set() - - return task, context["session_id"], context["response"] - - def test_integration_patches_server(sentry_init): """Test that MCPIntegration patches the Server class""" # Get original methods before integration @@ -1186,7 +1090,7 @@ async def async_tool(tool_name, arguments): @pytest.mark.asyncio -async def test_sse_transport_detection(sentry_init, capture_events): +async def test_sse_transport_detection(sentry_init, capture_events, json_rpc_sse): """Test that SSE transport is correctly detected via query parameter""" sentry_init( integrations=[MCPIntegration()], From 8c9aa863cee3d7f93d5d796658b488b028579291 Mon Sep 17 00:00:00 2001 From: Alexander Alderman Webb Date: Fri, 30 Jan 2026 13:33:37 +0100 Subject: [PATCH 12/28] remove mixed method test --- tests/integrations/mcp/test_mcp.py | 37 ------------------------------ 1 file changed, 37 deletions(-) diff --git a/tests/integrations/mcp/test_mcp.py b/tests/integrations/mcp/test_mcp.py index 3479590cfe..dde7e8946c 100644 --- a/tests/integrations/mcp/test_mcp.py +++ b/tests/integrations/mcp/test_mcp.py @@ -1039,43 +1039,6 @@ def test_tool_complex(tool_name, arguments): assert span["data"]["mcp.request.argument.number"] == "42" -@pytest.mark.asyncio -async def test_async_handlers_mixed(sentry_init, capture_events): - """Test mixing sync and async handlers in the same transaction""" - sentry_init( - integrations=[MCPIntegration()], - traces_sample_rate=1.0, - ) - events = capture_events() - - server = Server("test-server") - - # Set up mock request context - mock_ctx = MockRequestContext(request_id="req-mixed", transport="stdio") - request_ctx.set(mock_ctx) - - @server.call_tool() - def sync_tool(tool_name, arguments): - return {"type": "sync"} - - @server.call_tool() - async def async_tool(tool_name, arguments): - return {"type": "async"} - - with start_transaction(name="mcp tx"): - sync_result = sync_tool("sync", {}) - async_result = await async_tool("async", {}) - - assert sync_result["type"] == "sync" - assert async_result["type"] == "async" - - (tx,) = events - assert len(tx["spans"]) == 2 - - # Both should be instrumented correctly - assert all(span["op"] == OP.MCP_SERVER for span in tx["spans"]) - - def test_sse_transport_detection(sentry_init, capture_events): """Test that SSE transport is correctly detected via query parameter""" sentry_init( From 65799353e5e07be25f4342088485c994c2d43d13 Mon Sep 17 00:00:00 2001 From: Alexander Alderman Webb Date: Fri, 30 Jan 2026 13:34:26 +0100 Subject: [PATCH 13/28] rename tests --- tests/integrations/mcp/test_mcp.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/integrations/mcp/test_mcp.py b/tests/integrations/mcp/test_mcp.py index dde7e8946c..ffbdb622a9 100644 --- a/tests/integrations/mcp/test_mcp.py +++ b/tests/integrations/mcp/test_mcp.py @@ -410,7 +410,7 @@ def failing_tool(tool_name, arguments): "send_default_pii, include_prompts", [(True, True), (True, False), (False, True), (False, False)], ) -async def test_prompt_handler_sync( +async def test_prompt_handler_stdio( sentry_init, capture_events, send_default_pii, include_prompts ): """Test that synchronous prompt handlers create proper spans""" @@ -574,7 +574,7 @@ async def failing_prompt(name, arguments): @pytest.mark.asyncio -async def test_resource_handler_sync(sentry_init, capture_events): +async def test_resource_handler_stdio(sentry_init, capture_events): """Test that synchronous resource handlers create proper spans""" sentry_init( integrations=[MCPIntegration()], From 30e9ec311f2d991b41ec6b20ce7e1c62ca208814 Mon Sep 17 00:00:00 2001 From: Alexander Alderman Webb Date: Fri, 30 Jan 2026 13:38:11 +0100 Subject: [PATCH 14/28] rename tests --- tests/integrations/mcp/test_mcp.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/integrations/mcp/test_mcp.py b/tests/integrations/mcp/test_mcp.py index f45564d26b..3302378160 100644 --- a/tests/integrations/mcp/test_mcp.py +++ b/tests/integrations/mcp/test_mcp.py @@ -287,7 +287,7 @@ async def test_tool(tool_name, arguments): "send_default_pii, include_prompts", [(True, True), (True, False), (False, True), (False, False)], ) -async def test_tool_handler_async( +async def test_tool_handler_streamble_http( sentry_init, capture_events, send_default_pii, include_prompts ): """Test that async tool handlers create proper spans""" @@ -501,7 +501,7 @@ async def test_prompt(name, arguments): "send_default_pii, include_prompts", [(True, True), (True, False), (False, True), (False, False)], ) -async def test_prompt_handler_async( +async def test_prompt_handler_streamble_http( sentry_init, capture_events, send_default_pii, include_prompts ): """Test that async prompt handlers create proper spans""" @@ -665,7 +665,7 @@ async def test_resource(uri): @pytest.mark.asyncio -async def test_resource_handler_async(sentry_init, capture_events): +async def test_resource_handler_streamble_http(sentry_init, capture_events): """Test that async resource handlers create proper spans""" sentry_init( integrations=[MCPIntegration()], From 1380f56ba25450e8e00da06d261e80c4023140cb Mon Sep 17 00:00:00 2001 From: Alexander Alderman Webb Date: Fri, 30 Jan 2026 13:54:10 +0100 Subject: [PATCH 15/28] remove mocks --- tests/integrations/mcp/test_mcp.py | 28 ---------------------------- 1 file changed, 28 deletions(-) diff --git a/tests/integrations/mcp/test_mcp.py b/tests/integrations/mcp/test_mcp.py index 0363b1a0d2..b2931649e2 100644 --- a/tests/integrations/mcp/test_mcp.py +++ b/tests/integrations/mcp/test_mcp.py @@ -132,19 +132,6 @@ def reset_request_ctx(): pass -# Mock MCP types and structures -class MockURI: - """Mock URI object for resource testing""" - - def __init__(self, uri_string): - self.scheme = uri_string.split("://")[0] if "://" in uri_string else "" - self.path = uri_string.split("://")[1] if "://" in uri_string else uri_string - self._uri_string = uri_string - - def __str__(self): - return self._uri_string - - class MockRequestContext: """Mock MCP request context""" @@ -180,21 +167,6 @@ def __init__(self, text): self.text = text -class MockPromptMessage: - """Mock PromptMessage object""" - - def __init__(self, role, content_text): - self.role = role - self.content = MockTextContent(content_text) - - -class MockGetPromptResult: - """Mock GetPromptResult object""" - - def __init__(self, messages): - self.messages = messages - - def test_integration_patches_server(sentry_init): """Test that MCPIntegration patches the Server class""" # Get original methods before integration From 5976135298eef4077a4a453d60634df22d68bee3 Mon Sep 17 00:00:00 2001 From: Alexander Alderman Webb Date: Fri, 30 Jan 2026 14:25:36 +0100 Subject: [PATCH 16/28] remove test --- tests/integrations/mcp/test_mcp.py | 34 ------------------------------ 1 file changed, 34 deletions(-) diff --git a/tests/integrations/mcp/test_mcp.py b/tests/integrations/mcp/test_mcp.py index ffbdb622a9..b6a09b7dc7 100644 --- a/tests/integrations/mcp/test_mcp.py +++ b/tests/integrations/mcp/test_mcp.py @@ -963,40 +963,6 @@ def test_prompt_dict(name, arguments): assert SPANDATA.MCP_PROMPT_RESULT_MESSAGE_CONTENT not in span["data"] -@pytest.mark.asyncio -@pytest.mark.skip -async def test_resource_without_protocol(sentry_init, capture_events): - """Test resource handler with URI without protocol scheme""" - sentry_init( - integrations=[MCPIntegration()], - traces_sample_rate=1.0, - ) - events = capture_events() - - server = Server("test-server") - - @server.read_resource() - def test_resource(uri): - return {"data": "test"} - - with start_transaction(name="mcp tx"): - await stdio( - server, - method="resources/read", - params={ - "uri": "https://example.com/resource", - }, - request_id="req-no-proto", - ) - - (tx,) = events - span = tx["spans"][0] - - assert span["data"][SPANDATA.MCP_RESOURCE_URI] == "simple-path" - # No protocol should be set - assert SPANDATA.MCP_RESOURCE_PROTOCOL not in span["data"] - - @pytest.mark.asyncio async def test_tool_with_complex_arguments(sentry_init, capture_events): """Test tool handler with complex nested arguments""" From 0bd14e371f584a0687592a4b753813fabccc0104 Mon Sep 17 00:00:00 2001 From: Alexander Alderman Webb Date: Fri, 30 Jan 2026 14:53:56 +0100 Subject: [PATCH 17/28] address comments --- tests/integrations/fastmcp/test_fastmcp.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/integrations/fastmcp/test_fastmcp.py b/tests/integrations/fastmcp/test_fastmcp.py index 4391499e78..bcf0cae574 100644 --- a/tests/integrations/fastmcp/test_fastmcp.py +++ b/tests/integrations/fastmcp/test_fastmcp.py @@ -914,6 +914,7 @@ async def read_url(resource: str): params={ "uri": "https://example.com/resource", }, + request_id="req-async-resource", ) # Older FastMCP versions may not support this URI pattern if ( @@ -925,14 +926,13 @@ async def read_url(resource: str): assert "resource data" in result.json()["result"]["contents"][0]["text"] - transactions = select_mcp_transactions(events, "resources/read") + transactions = select_mcp_transactions(events) assert len(transactions) == 1 tx = transactions[0] - - # Verify span was created - resource_spans = [s for s in tx["spans"] if s["op"] == OP.MCP_SERVER] - assert len(resource_spans) == 1 - assert resource_spans[0]["data"][SPANDATA.MCP_RESOURCE_PROTOCOL] == "https" + assert ( + tx["contexts"]["trace"]["data"][SPANDATA.MCP_RESOURCE_PROTOCOL] + == "https" + ) except (AttributeError, TypeError): # Resource handler not supported in this version pytest.skip("Resource handlers not supported in this FastMCP version") From ef33e1f3719f53261ad8424886665e809661fbfc Mon Sep 17 00:00:00 2001 From: Alexander Alderman Webb Date: Fri, 30 Jan 2026 14:56:42 +0100 Subject: [PATCH 18/28] add missing import fallback --- tests/conftest.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/conftest.py b/tests/conftest.py index 6547251ac0..09aae50d8c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -60,6 +60,7 @@ create_memory_object_stream = None create_task_group = None JSONRPCMessage = None + JSONRPCNotification = None JSONRPCRequest = None SessionMessage = None From d6c2fa574b3ab218a6e95b1d744b06e042dc965e Mon Sep 17 00:00:00 2001 From: Alexander Alderman Webb Date: Fri, 30 Jan 2026 15:00:40 +0100 Subject: [PATCH 19/28] simplify streaming transport --- tests/integrations/mcp/streaming_asgi_transport.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/integrations/mcp/streaming_asgi_transport.py b/tests/integrations/mcp/streaming_asgi_transport.py index 318705b1cb..681a5bc96e 100644 --- a/tests/integrations/mcp/streaming_asgi_transport.py +++ b/tests/integrations/mcp/streaming_asgi_transport.py @@ -66,9 +66,8 @@ async def run_app(): await self.app(scope, receive, send) class StreamingBodyStream(AsyncByteStream): - def __init__(self, receiver, task): + def __init__(self, receiver): self.receiver = receiver - self.task = task async def __aiter__(self): try: @@ -77,7 +76,8 @@ async def __aiter__(self): except anyio.EndOfStream: pass - stream = StreamingBodyStream(body_receiver, asyncio.create_task(run_app())) + stream = StreamingBodyStream(body_receiver) response = Response(status_code=200, headers=[], stream=stream) + asyncio.create_task(run_app()) return response From 7484fd859a0956748f5400f86c8103bbcd8a1e39 Mon Sep 17 00:00:00 2001 From: Alexander Alderman Webb Date: Fri, 30 Jan 2026 15:04:13 +0100 Subject: [PATCH 20/28] re-organize stream parsing --- tests/conftest.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index d4ec78a0eb..9e76bf2d2f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -917,16 +917,16 @@ async def parse_stream(): and b"structuredContent" in chunk ): context["response"] = parse_sse_data_package(chunk) - stream_complete.set() break elif ( "result" in parse_sse_data_package(chunk) and "content" in parse_sse_data_package(chunk)["result"] ): context["response"] = parse_sse_data_package(chunk) - stream_complete.set() break + stream_complete.set() + task = asyncio.create_task(parse_stream()) await endpoint_parsed.wait() From 7c9d6026901e833486d857573cab7ae6b775bfec Mon Sep 17 00:00:00 2001 From: Alexander Alderman Webb Date: Fri, 30 Jan 2026 15:16:12 +0100 Subject: [PATCH 21/28] stop shadowing werkzeug types --- tests/conftest.py | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 9e76bf2d2f..f5e7c67809 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -64,7 +64,13 @@ JSONRPCRequest, ) from mcp.shared.message import SessionMessage - from httpx import ASGITransport, Request, Response, AsyncByteStream, AsyncClient + from httpx import ( + ASGITransport, + Request as HttpxRequest, + Response as HttpxResponse, + AsyncByteStream, + AsyncClient, + ) except ImportError: create_memory_object_stream = None create_task_group = None @@ -76,8 +82,8 @@ SessionMessage = None ASGITransport = None - Request = None - Response = None + HttpxRequest = None + HttpxResponse = None AsyncByteStream = None AsyncClient = None @@ -814,7 +820,9 @@ def __init__( self.keep_sse_alive = keep_sse_alive super().__init__(app) - async def handle_async_request(self, request: "Request") -> "Response": + async def handle_async_request( + self, request: "HttpxRequest" + ) -> "HttpxResponse": scope = { "type": "http", "method": request.method, @@ -873,7 +881,7 @@ async def __aiter__(self): pass stream = StreamingBodyStream(body_receiver) - response = Response(status_code=200, headers=[], stream=stream) # type: ignore + response = HttpxResponse(status_code=200, headers=[], stream=stream) # type: ignore asyncio.create_task(run_app()) return response From 0f47f063190cd2b84c1d02bc25333310d42c8ed0 Mon Sep 17 00:00:00 2001 From: Alexander Alderman Webb Date: Fri, 30 Jan 2026 15:20:22 +0100 Subject: [PATCH 22/28] remove unused import --- tests/integrations/mcp/test_mcp.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/integrations/mcp/test_mcp.py b/tests/integrations/mcp/test_mcp.py index 9eaf4e7bf1..8569ad18e4 100644 --- a/tests/integrations/mcp/test_mcp.py +++ b/tests/integrations/mcp/test_mcp.py @@ -15,8 +15,6 @@ that the integration properly instruments MCP handlers with Sentry spans. """ -import sentry_sdk - from urllib.parse import urlparse, parse_qs import anyio import asyncio From 1bf68763be44e61e0e36659a15ffbd3f487c33a3 Mon Sep 17 00:00:00 2001 From: Alexander Alderman Webb Date: Mon, 2 Feb 2026 08:32:59 +0100 Subject: [PATCH 23/28] simplify assertions and use stdio in more tests cases --- tests/integrations/fastmcp/test_fastmcp.py | 141 ++++++++------------- 1 file changed, 52 insertions(+), 89 deletions(-) diff --git a/tests/integrations/fastmcp/test_fastmcp.py b/tests/integrations/fastmcp/test_fastmcp.py index f38df8d8c9..c7e9709111 100644 --- a/tests/integrations/fastmcp/test_fastmcp.py +++ b/tests/integrations/fastmcp/test_fastmcp.py @@ -72,12 +72,6 @@ async def __call__(self, *args, **kwargs): ReadResourceRequest = None -try: - from fastmcp import __version__ as FASTMCP_VERSION -except ImportError: - FASTMCP_VERSION = None - - # Collect available FastMCP implementations for parametrization fastmcp_implementations = [] fastmcp_ids = [] @@ -320,24 +314,10 @@ def add_numbers(a: int, b: int) -> dict: request_id="req-123", ) - if ( - isinstance(mcp, StandaloneFastMCP) - and FASTMCP_VERSION is not None - and FASTMCP_VERSION.startswith("2") - ): - assert result.message.root.result["content"][0]["text"] == json.dumps( - {"result": 15, "operation": "addition"}, separators=(",", ":") - ) - elif ( - isinstance(mcp, StandaloneFastMCP) and FASTMCP_VERSION is not None - ): # Checking for None is not precise. - assert result.message.root.result["content"][0]["text"] == json.dumps( - {"result": 15, "operation": "addition"} - ) - else: - assert result.message.root.result["content"][0]["text"] == json.dumps( - {"result": 15, "operation": "addition"}, indent=2 - ) + assert json.loads(result.message.root.result["content"][0]["text"]) == { + "result": 15, + "operation": "addition", + } (tx,) = events assert tx["type"] == "transaction" @@ -581,41 +561,12 @@ def get_user_data(user_id: int) -> dict: request_id="req-complex", ) - if ( - isinstance(mcp, StandaloneFastMCP) - and FASTMCP_VERSION is not None - and FASTMCP_VERSION.startswith("2") - ): - assert result.message.root.result["content"][0]["text"] == json.dumps( - { - "id": 123, - "name": "Alice", - "nested": {"preferences": {"theme": "dark", "notifications": True}}, - "tags": ["admin", "verified"], - }, - separators=(",", ":"), - ) - elif ( - isinstance(mcp, StandaloneFastMCP) and FASTMCP_VERSION is not None - ): # Checking for None is not precise. - assert result.message.root.result["content"][0]["text"] == json.dumps( - { - "id": 123, - "name": "Alice", - "nested": {"preferences": {"theme": "dark", "notifications": True}}, - "tags": ["admin", "verified"], - } - ) - else: - assert result.message.root.result["content"][0]["text"] == json.dumps( - { - "id": 123, - "name": "Alice", - "nested": {"preferences": {"theme": "dark", "notifications": True}}, - "tags": ["admin", "verified"], - }, - indent=2, - ) + assert json.loads(result.message.root.result["content"][0]["text"]) == { + "id": 123, + "name": "Alice", + "nested": {"preferences": {"theme": "dark", "notifications": True}}, + "tags": ["admin", "verified"], + } (tx,) = events assert tx["type"] == "transaction" @@ -1057,24 +1008,9 @@ def stdio_tool(n: int) -> dict: request_id="req-stdio", ) - if ( - isinstance(mcp, StandaloneFastMCP) - and FASTMCP_VERSION is not None - and FASTMCP_VERSION.startswith("2") - ): - assert result.message.root.result["content"][0]["text"] == json.dumps( - {"squared": 49}, separators=(",", ":") - ) - elif ( - isinstance(mcp, StandaloneFastMCP) and FASTMCP_VERSION is not None - ): # Checking for None is not precise. - assert result.message.root.result["content"][0]["text"] == json.dumps( - {"squared": 49} - ) - else: - assert result.message.root.result["content"][0]["text"] == json.dumps( - {"squared": 49}, indent=2 - ) + assert json.loads(result.message.root.result["content"][0]["text"]) == { + "squared": 49 + } (tx,) = events @@ -1118,10 +1054,11 @@ def package_specific_tool(x: int) -> int: assert tx["type"] == "transaction" +@pytest.mark.asyncio @pytest.mark.skipif( not HAS_STANDALONE_FASTMCP, reason="standalone fastmcp not installed" ) -def test_standalone_fastmcp_specific_features(sentry_init, capture_events): +async def test_standalone_fastmcp_specific_features(sentry_init, capture_events, stdio): """Test features specific to standalone fastmcp package""" sentry_init( integrations=[MCPIntegration()], @@ -1139,12 +1076,19 @@ def standalone_specific_tool(message: str) -> dict: return {"echo": message, "length": len(message)} with start_transaction(name="standalone fastmcp tx"): - result = call_tool_through_mcp( - mcp, "standalone_specific_tool", {"message": "Hello FastMCP"} + result = await stdio( + mcp._mcp_server, + method="tools/call", + params={ + "name": "standalone_specific_tool", + "arguments": {"message": "Hello FastMCP"}, + }, ) - assert result["echo"] == "Hello FastMCP" - assert result["length"] == 13 + assert json.loads(result.message.root.result["content"][0]["text"]) == { + "echo": "Hello FastMCP", + "length": 13, + } (tx,) = events assert tx["type"] == "transaction" @@ -1155,8 +1099,11 @@ def standalone_specific_tool(message: str) -> dict: # ============================================================================= +@pytest.mark.asyncio @pytest.mark.parametrize("FastMCP", fastmcp_implementations, ids=fastmcp_ids) -def test_fastmcp_tool_with_no_arguments(sentry_init, capture_events, FastMCP): +async def test_fastmcp_tool_with_no_arguments( + sentry_init, capture_events, FastMCP, stdio +): """Test FastMCP tool with no arguments""" sentry_init( integrations=[MCPIntegration()], @@ -1172,16 +1119,26 @@ def no_args_tool() -> str: return "success" with start_transaction(name="fastmcp tx"): - result = call_tool_through_mcp(mcp, "no_args_tool", {}) + result = await stdio( + mcp._mcp_server, + method="tools/call", + params={ + "name": "no_args_tool", + "arguments": {}, + }, + ) - assert result["result"] == "success" + assert result.message.root.result["content"][0]["text"] == "success" (tx,) = events assert tx["type"] == "transaction" +@pytest.mark.asyncio @pytest.mark.parametrize("FastMCP", fastmcp_implementations, ids=fastmcp_ids) -def test_fastmcp_tool_with_none_return(sentry_init, capture_events, FastMCP): +async def test_fastmcp_tool_with_none_return( + sentry_init, capture_events, FastMCP, stdio +): """Test FastMCP tool that returns None""" sentry_init( integrations=[MCPIntegration()], @@ -1197,10 +1154,16 @@ def none_return_tool(action: str) -> None: pass with start_transaction(name="fastmcp tx"): - result = call_tool_through_mcp(mcp, "none_return_tool", {"action": "log"}) + result = await stdio( + mcp._mcp_server, + method="tools/call", + params={ + "name": "none_return_tool", + "arguments": {"action": "log"}, + }, + ) - # Helper function normalizes to {"result": value} format - assert result["result"] is None + assert len(result.message.root.result["content"]) == 0 (tx,) = events assert tx["type"] == "transaction" From 771f60e07754a130b39c8edc63c55222cc60d149 Mon Sep 17 00:00:00 2001 From: Alexander Alderman Webb Date: Mon, 2 Feb 2026 08:34:31 +0100 Subject: [PATCH 24/28] forgot conftest --- tests/conftest.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/conftest.py b/tests/conftest.py index 09aae50d8c..f3168e4287 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -667,7 +667,10 @@ def stdio( get_initialized_notification_payload, get_mcp_command_payload, ): - async def inner(server, method: str, params, request_id: str): + async def inner(server, method: str, params, request_id: str | None = None): + if request_id is None: + request_id = "1" + read_stream_writer, read_stream = create_memory_object_stream(0) # type: ignore write_stream, write_stream_reader = create_memory_object_stream(0) # type: ignore From ef06a2da16366cd24a8cb425321ce4aab23474a9 Mon Sep 17 00:00:00 2001 From: Alexander Alderman Webb Date: Mon, 2 Feb 2026 08:48:00 +0100 Subject: [PATCH 25/28] fix no-return assertion --- tests/integrations/fastmcp/test_fastmcp.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/tests/integrations/fastmcp/test_fastmcp.py b/tests/integrations/fastmcp/test_fastmcp.py index c7e9709111..4fb7a1b8a0 100644 --- a/tests/integrations/fastmcp/test_fastmcp.py +++ b/tests/integrations/fastmcp/test_fastmcp.py @@ -71,6 +71,10 @@ async def __call__(self, *args, **kwargs): GetPromptRequest = None ReadResourceRequest = None +try: + from fastmcp import __version__ as FASTMCP_VERSION +except ImportError: + FASTMCP_VERSION = None # Collect available FastMCP implementations for parametrization fastmcp_implementations = [] @@ -1163,7 +1167,14 @@ def none_return_tool(action: str) -> None: }, ) - assert len(result.message.root.result["content"]) == 0 + if ( + isinstance(mcp, StandaloneFastMCP) and FASTMCP_VERSION is not None + ) or isinstance(mcp, MCPFastMCP): + assert len(result.message.root.result["content"]) == 0 + else: + assert result.message.root.result["content"] == [ + {"type": "text", "text": "None"} + ] (tx,) = events assert tx["type"] == "transaction" From 8d6744cea4d9538f6c188bc0b4878456c394917b Mon Sep 17 00:00:00 2001 From: Alexander Alderman Webb Date: Mon, 2 Feb 2026 08:59:01 +0100 Subject: [PATCH 26/28] simplify assertion --- tests/integrations/fastmcp/test_fastmcp.py | 21 +++------------------ 1 file changed, 3 insertions(+), 18 deletions(-) diff --git a/tests/integrations/fastmcp/test_fastmcp.py b/tests/integrations/fastmcp/test_fastmcp.py index cc5e7f5a00..2d2e6f02c6 100644 --- a/tests/integrations/fastmcp/test_fastmcp.py +++ b/tests/integrations/fastmcp/test_fastmcp.py @@ -994,24 +994,9 @@ def sse_tool(value: str) -> dict: await sse_connection_closed.wait() await app_task - if ( - isinstance(mcp, StandaloneFastMCP) - and FASTMCP_VERSION is not None - and FASTMCP_VERSION.startswith("2") - ): - assert result["result"]["content"][0]["text"] == json.dumps( - {"message": "Received: hello"}, separators=(",", ":") - ) - elif ( - isinstance(mcp, StandaloneFastMCP) and FASTMCP_VERSION is not None - ): # Checking for None is not precise. - assert result["result"]["content"][0]["text"] == json.dumps( - {"message": "Received: hello"} - ) - else: - assert result["result"]["content"][0]["text"] == json.dumps( - {"message": "Received: hello"}, indent=2 - ) + assert json.loads(result["result"]["content"][0]["text"]) == { + "message": "Received: hello" + } transactions = [ event From e5544416eaf46bf899f269de695a8db35d34111e Mon Sep 17 00:00:00 2001 From: Alexander Alderman Webb Date: Mon, 2 Feb 2026 13:23:11 +0100 Subject: [PATCH 27/28] remove unused file and import order --- .../mcp/streaming_asgi_transport.py | 83 ------------------- tests/integrations/mcp/test_mcp.py | 4 +- 2 files changed, 2 insertions(+), 85 deletions(-) delete mode 100644 tests/integrations/mcp/streaming_asgi_transport.py diff --git a/tests/integrations/mcp/streaming_asgi_transport.py b/tests/integrations/mcp/streaming_asgi_transport.py deleted file mode 100644 index 681a5bc96e..0000000000 --- a/tests/integrations/mcp/streaming_asgi_transport.py +++ /dev/null @@ -1,83 +0,0 @@ -import asyncio -from httpx import ASGITransport, Request, Response, AsyncByteStream -import anyio - -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from typing import Any, Callable, MutableMapping - - -class StreamingASGITransport(ASGITransport): - """ - Simple transport whose only purpose is to keep GET request alive in SSE connections, allowing - tests involving SSE interactions to run in-process. - """ - - def __init__( - self, - app: "Callable", - keep_sse_alive: "asyncio.Event", - ) -> None: - self.keep_sse_alive = keep_sse_alive - super().__init__(app) - - async def handle_async_request(self, request: "Request") -> "Response": - scope = { - "type": "http", - "method": request.method, - "headers": [(k.lower(), v) for (k, v) in request.headers.raw], - "path": request.url.path, - "query_string": request.url.query, - } - - is_streaming_sse = scope["method"] == "GET" and scope["path"] == "/sse" - if not is_streaming_sse: - return await super().handle_async_request(request) - - request_body = b"" - if request.content: - request_body = await request.aread() - - body_sender, body_receiver = anyio.create_memory_object_stream[bytes](0) - - async def receive() -> "dict[str, Any]": - if self.keep_sse_alive.is_set(): - return {"type": "http.disconnect"} - - await self.keep_sse_alive.wait() # Keep alive :) - return {"type": "http.request", "body": request_body, "more_body": False} - - async def send(message: "MutableMapping[str, Any]") -> None: - if message["type"] == "http.response.body": - body = message.get("body", b"") - more_body = message.get("more_body", False) - - if body == b"" and not more_body: - return - - if body: - await body_sender.send(body) - - if not more_body: - await body_sender.aclose() - - async def run_app(): - await self.app(scope, receive, send) - - class StreamingBodyStream(AsyncByteStream): - def __init__(self, receiver): - self.receiver = receiver - - async def __aiter__(self): - try: - async for chunk in self.receiver: - yield chunk - except anyio.EndOfStream: - pass - - stream = StreamingBodyStream(body_receiver) - response = Response(status_code=200, headers=[], stream=stream) - - asyncio.create_task(run_app()) - return response diff --git a/tests/integrations/mcp/test_mcp.py b/tests/integrations/mcp/test_mcp.py index 9441092996..715f9734ca 100644 --- a/tests/integrations/mcp/test_mcp.py +++ b/tests/integrations/mcp/test_mcp.py @@ -31,10 +31,10 @@ async def __call__(self, *args, **kwargs): return super(AsyncMock, self).__call__(*args, **kwargs) -from mcp.types import GetPromptResult, PromptMessage, TextContent -from mcp.server.lowlevel.helper_types import ReadResourceContents from mcp.server.lowlevel import Server from mcp.server.lowlevel.server import request_ctx +from mcp.types import GetPromptResult, PromptMessage, TextContent +from mcp.server.lowlevel.helper_types import ReadResourceContents try: from mcp.server.lowlevel.server import request_ctx From 99571ae65811e877e74d3b73d5307ef95c9979a4 Mon Sep 17 00:00:00 2001 From: Alexander Alderman Webb Date: Mon, 2 Feb 2026 13:36:06 +0100 Subject: [PATCH 28/28] remove unused param --- tests/conftest.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 36741b7661..1cf29bf977 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -805,7 +805,7 @@ def inner(events): @pytest.fixture() -def json_rpc_sse(is_structured_content: bool = True): +def json_rpc_sse(): class StreamingASGITransport(ASGITransport): """ Simple transport whose only purpose is to keep GET request alive in SSE connections, allowing @@ -919,11 +919,7 @@ async def parse_stream(): endpoint_parsed.set() continue - if ( - is_structured_content - and b"event: message" in chunk - and b"structuredContent" in chunk - ): + if b"event: message" in chunk and b"structuredContent" in chunk: context["response"] = parse_sse_data_package(chunk) break elif (