From ff0beae413b171883a6d0c69629833b4b2bb3f5b Mon Sep 17 00:00:00 2001 From: betegon Date: Wed, 7 Oct 2026 20:25:30 +0200 Subject: [PATCH 1/2] fix(mcp): capture transport implementation and HTTP metadata --- sentry_sdk/consts.py | 10 ++- sentry_sdk/integrations/mcp.py | 93 +++++++++++++---------- tests/integrations/mcp/test_mcp.py | 117 ++++++++++++++++++++++------- 3 files changed, 153 insertions(+), 67 deletions(-) diff --git a/sentry_sdk/consts.py b/sentry_sdk/consts.py index 5b037a66d0..8ca7277462 100644 --- a/sentry_sdk/consts.py +++ b/sentry_sdk/consts.py @@ -965,6 +965,12 @@ class SPANDATA: Example: "http", "https" """ + NETWORK_PROTOCOL_VERSION = "network.protocol.version" + """ + The version of the application layer protocol used for the network connection. + Example: "1.1", "2", "3" + """ + NETWORK_PEER_ADDRESS = "network.peer.address" """ Peer address of the network connection - IP address or Unix domain socket name. @@ -1184,8 +1190,8 @@ class SPANDATA: MCP_TRANSPORT = "mcp.transport" """ - The transport method used for MCP communication. - Example: "http", "sse", "stdio" + The MCP transport implementation name, separate from the network transport. + Example: "StreamableHTTPServerTransport", "CustomHTTPTransport" """ MCP_SESSION_ID = "mcp.session.id" diff --git a/sentry_sdk/integrations/mcp.py b/sentry_sdk/integrations/mcp.py index 5fc186f505..268f5217b5 100644 --- a/sentry_sdk/integrations/mcp.py +++ b/sentry_sdk/integrations/mcp.py @@ -31,6 +31,7 @@ try: from mcp.server.lowlevel import Server + from mcp.server.sse import SseServerTransport from mcp.server.streamable_http import ( StreamableHTTPServerTransport, ) @@ -97,6 +98,7 @@ def setup_once() -> None: """ _patch_lowlevel_server() _patch_handle_request() + _patch_sse_handle_post_message() if FastMCP is not None: _patch_fastmcp() @@ -148,37 +150,37 @@ def _active_http_scopes( def _get_request_context_data( ctx: "Any", -) -> "tuple[Optional[str], Optional[str], str]": - """ - Extract request ID, session ID, and MCP transport type from the request context. - - Returns: - Tuple of (request_id, session_id, mcp_transport). - - request_id: May be None if not available - - session_id: May be None if not available - - mcp_transport: "http", "sse", "stdio" - """ +) -> "tuple[Optional[str], Optional[str], dict[str, str]]": + """Extract request ID, session ID, and known transport attributes.""" request_id: "Optional[str]" = None session_id: "Optional[str]" = None - mcp_transport: str = "stdio" + transport_data: "dict[str, str]" = {} if ctx is not None: request_id = ctx.request_id - if hasattr(ctx, "request") and ctx.request is not None: - request = ctx.request - # Detect transport type by checking request characteristics + request = getattr(ctx, "request", None) + if request is not None: if hasattr(request, "query_params") and request.query_params.get( "session_id" ): - # SSE transport uses query parameter - mcp_transport = "sse" session_id = request.query_params.get("session_id") elif hasattr(request, "headers"): - # StreamableHTTP transport uses header - mcp_transport = "http" session_id = request.headers.get("mcp-session-id") - return request_id, session_id, mcp_transport + scope = getattr(request, "scope", {}) + if scope.get("type") == "http": + transport_data[SPANDATA.NETWORK_PROTOCOL_NAME] = "http" + http_version = scope.get("http_version") + if isinstance(http_version, str) and http_version: + transport_data[SPANDATA.NETWORK_PROTOCOL_VERSION] = http_version + if http_version == "3": + transport_data[SPANDATA.NETWORK_TRANSPORT] = "quic" + + transport_name = scope.get("state", {}).get("sentry_sdk.mcp.transport") + if isinstance(transport_name, str): + transport_data[SPANDATA.MCP_TRANSPORT] = transport_name + + return request_id, session_id, transport_data def _set_span_input_data( @@ -189,7 +191,7 @@ def _set_span_input_data( arguments: "dict[str, Any]", request_id: "Optional[str]", session_id: "Optional[str]", - mcp_transport: str, + transport_data: "dict[str, str]", ) -> None: """Set input span data for MCP handlers.""" @@ -197,13 +199,8 @@ def _set_span_input_data( _set_span_data_attribute(span, span_data_key, handler_name) _set_span_data_attribute(span, SPANDATA.MCP_METHOD_NAME, mcp_method_name) - # Set transport/MCP transport type - _set_span_data_attribute( - span, - SPANDATA.NETWORK_TRANSPORT, - "pipe" if mcp_transport == "stdio" else "tcp", - ) - _set_span_data_attribute(span, SPANDATA.MCP_TRANSPORT, mcp_transport) + for key, value in transport_data.items(): + _set_span_data_attribute(span, key, value) # Set request_id if provided if request_id: @@ -315,7 +312,7 @@ async def _tool_handler_wrapper( pass # Get request ID, session ID, and transport from context - request_id, session_id, mcp_transport = _get_request_context_data(ctx=ctx) + request_id, session_id, transport_data = _get_request_context_data(ctx=ctx) span_streaming = has_span_streaming_enabled(sentry_sdk.get_client().options) @@ -347,7 +344,7 @@ async def _tool_handler_wrapper( arguments, request_id, session_id, - mcp_transport, + transport_data, ) try: @@ -418,7 +415,7 @@ async def _instrument_v2_tool_call( arguments = {} # Get request ID, session ID, and transport from context - request_id, session_id, mcp_transport = _get_request_context_data(ctx=ctx) + request_id, session_id, transport_data = _get_request_context_data(ctx=ctx) span_streaming = has_span_streaming_enabled(client.options) @@ -450,7 +447,7 @@ async def _instrument_v2_tool_call( arguments, request_id, session_id, - mcp_transport, + transport_data, ) try: @@ -545,7 +542,7 @@ async def _prompt_handler_wrapper( pass # Get request ID, session ID, and transport from context - request_id, session_id, mcp_transport = _get_request_context_data(ctx=ctx) + request_id, session_id, transport_data = _get_request_context_data(ctx=ctx) span_streaming = has_span_streaming_enabled(client.options) @@ -577,7 +574,7 @@ async def _prompt_handler_wrapper( arguments, request_id, session_id, - mcp_transport, + transport_data, ) try: @@ -702,7 +699,7 @@ async def _instrument_v2_prompt_get( arguments = {} # Get request ID, session ID, and transport from context - request_id, session_id, mcp_transport = _get_request_context_data(ctx=ctx) + request_id, session_id, transport_data = _get_request_context_data(ctx=ctx) span_streaming = has_span_streaming_enabled(client.options) @@ -734,7 +731,7 @@ async def _instrument_v2_prompt_get( arguments, request_id, session_id, - mcp_transport, + transport_data, ) try: @@ -841,7 +838,7 @@ async def _resource_handler_wrapper( pass # Get request ID, session ID, and transport from context - request_id, session_id, mcp_transport = _get_request_context_data(ctx=ctx) + request_id, session_id, transport_data = _get_request_context_data(ctx=ctx) span_streaming = has_span_streaming_enabled(sentry_sdk.get_client().options) @@ -873,7 +870,7 @@ async def _resource_handler_wrapper( arguments, request_id, session_id, - mcp_transport, + transport_data, ) if original_args: @@ -920,7 +917,7 @@ async def _instrument_v2_resource_read( handler_name = ctx.params["uri"] # Get request ID, session ID, and transport from context - request_id, session_id, mcp_transport = _get_request_context_data(ctx=ctx) + request_id, session_id, transport_data = _get_request_context_data(ctx=ctx) span_streaming = has_span_streaming_enabled(sentry_sdk.get_client().options) @@ -952,7 +949,7 @@ async def _instrument_v2_resource_read( {}, request_id, session_id, - mcp_transport, + transport_data, ) protocol = None @@ -1092,11 +1089,29 @@ async def patched_handle_request( sentry_sdk.get_isolation_scope() ) scope["state"]["sentry_sdk.current_scope"] = sentry_sdk.get_current_scope() + scope["state"]["sentry_sdk.mcp.transport"] = type(self).__name__ await original_handle_request(self, scope, receive, send) StreamableHTTPServerTransport.handle_request = patched_handle_request # type: ignore[method-assign] +def _patch_sse_handle_post_message() -> None: + """Keep the SSE implementation name with each message's request context.""" + original_handle_post_message = SseServerTransport.handle_post_message + + @wraps(original_handle_post_message) + async def patched_handle_post_message( + self: "SseServerTransport", + scope: "Scope", + receive: "Receive", + send: "Send", + ) -> None: + scope.setdefault("state", {})["sentry_sdk.mcp.transport"] = type(self).__name__ + await original_handle_post_message(self, scope, receive, send) + + SseServerTransport.handle_post_message = patched_handle_post_message # type: ignore[method-assign] + + def _patch_fastmcp() -> None: """ Patches the standalone fastmcp package's FastMCP class. diff --git a/tests/integrations/mcp/test_mcp.py b/tests/integrations/mcp/test_mcp.py index 15432d8d84..e232b9bc59 100644 --- a/tests/integrations/mcp/test_mcp.py +++ b/tests/integrations/mcp/test_mcp.py @@ -11,7 +11,7 @@ - Span data validation - Origin tracking -The tests drive real MCP servers over the stdio, StreamableHTTP, and SSE +The tests drive real MCP servers over in-memory streams, StreamableHTTP, and SSE transports to verify that the integration properly instruments MCP handlers with Sentry spans. """ @@ -66,6 +66,7 @@ async def __call__(self, *args, **kwargs): from mcp.types import GetPromptResult, PromptMessage, TextContent from mcp.server.sse import SseServerTransport +from mcp.server.streamable_http import StreamableHTTPServerTransport from mcp.server.streamable_http_manager import StreamableHTTPSessionManager from starlette.applications import Starlette from starlette.responses import Response @@ -344,7 +345,7 @@ async def test_tool(ctx, params): "send_default_pii, include_prompts", [(True, True), (True, False), (False, True), (False, False)], ) -async def test_tool_handler_stdio( +async def test_tool_handler_in_memory( sentry_init, capture_events, capture_items, @@ -441,8 +442,10 @@ async def test_tool(tool_name, arguments): # Check span data assert data[SPANDATA.MCP_TOOL_NAME] == "calculate" assert data[SPANDATA.MCP_METHOD_NAME] == "tools/call" - assert data[SPANDATA.MCP_TRANSPORT] == "stdio" - assert data[SPANDATA.NETWORK_TRANSPORT] == "pipe" + assert SPANDATA.MCP_TRANSPORT not in data + assert SPANDATA.NETWORK_TRANSPORT not in data + assert SPANDATA.NETWORK_PROTOCOL_NAME not in data + assert SPANDATA.NETWORK_PROTOCOL_VERSION not in data assert data[SPANDATA.MCP_REQUEST_ID] == "req-123" assert SPANDATA.MCP_SESSION_ID not in data assert data["mcp.request.argument.x"] == "10" @@ -581,8 +584,10 @@ async def test_tool_async(tool_name, arguments): # Check span data assert data[SPANDATA.MCP_TOOL_NAME] == "process" assert data[SPANDATA.MCP_METHOD_NAME] == "tools/call" - assert data[SPANDATA.MCP_TRANSPORT] == "http" - assert data[SPANDATA.NETWORK_TRANSPORT] == "tcp" + assert data[SPANDATA.MCP_TRANSPORT] == "StreamableHTTPServerTransport" + assert data[SPANDATA.NETWORK_PROTOCOL_NAME] == "http" + assert data[SPANDATA.NETWORK_PROTOCOL_VERSION] == "1.1" + assert SPANDATA.NETWORK_TRANSPORT not in data assert data[SPANDATA.MCP_REQUEST_ID] == "req-456" assert data[SPANDATA.MCP_SESSION_ID] == session_id assert data["mcp.request.argument.data"] == "test" @@ -598,14 +603,21 @@ async def test_tool_async(tool_name, arguments): @pytest.mark.asyncio @pytest.mark.parametrize("span_streaming", [True, False]) +@pytest.mark.parametrize( + "http_version, custom_transport", + [("1.1", False), ("2", True), ("3", True), (None, True)], +) async def test_tool_handler_stateless_streamable_http( sentry_init, capture_events, capture_items, select_transactions_with_mcp_spans, span_streaming, + http_version, + custom_transport, + monkeypatch, ): - """A stateless StreamableHTTP server is still reported as the http transport. + """A stateless server records its implementation and known HTTP attributes. Such a server issues no session id, so the client sends no `mcp-session-id` header. That says nothing about the transport. @@ -616,7 +628,25 @@ async def test_tool_handler_stateless_streamable_http( trace_lifecycle="stream" if span_streaming else "static", ) + class CustomHTTPTransport(StreamableHTTPServerTransport): + pass + + if custom_transport: + monkeypatch.setattr( + "mcp.server.streamable_http_manager.StreamableHTTPServerTransport", + CustomHTTPTransport, + ) + server = Server("test-server") + app = _streamable_http_app(server, stateless=True) + + async def app_with_http_version(scope, receive, send): + if scope["type"] == "http": + if http_version is None: + scope.pop("http_version", None) + else: + scope["http_version"] = http_version + await app(scope, receive, send) if IS_MCP_V2: @@ -635,7 +665,7 @@ async def test_tool_async(tool_name, arguments): # A stateless server accepts each request on its own, so there is no # handshake to replay and no session id to echo back. - with TestClient(_streamable_http_app(server, stateless=True)) as client: + with TestClient(app_with_http_version) as client: response = client.post( "/mcp/", headers={ @@ -661,7 +691,7 @@ async def test_tool_async(tool_name, arguments): # A stateless server accepts each request on its own, so there is no # handshake to replay and no session id to echo back. - with TestClient(_streamable_http_app(server, stateless=True)) as client: + with TestClient(app_with_http_version) as client: response = client.post( "/mcp/", headers={ @@ -684,8 +714,18 @@ async def test_tool_async(tool_name, arguments): assert len(transactions) == 1 data = transactions[0]["spans"][0]["data"] - assert data[SPANDATA.MCP_TRANSPORT] == "http" - assert data[SPANDATA.NETWORK_TRANSPORT] == "tcp" + assert data[SPANDATA.MCP_TRANSPORT] == ( + "CustomHTTPTransport" if custom_transport else "StreamableHTTPServerTransport" + ) + assert data[SPANDATA.NETWORK_PROTOCOL_NAME] == "http" + if http_version is None: + assert SPANDATA.NETWORK_PROTOCOL_VERSION not in data + else: + assert data[SPANDATA.NETWORK_PROTOCOL_VERSION] == http_version + if http_version == "3": + assert data[SPANDATA.NETWORK_TRANSPORT] == "quic" + else: + assert SPANDATA.NETWORK_TRANSPORT not in data assert data[SPANDATA.MCP_REQUEST_ID] == "req-789" assert SPANDATA.MCP_SESSION_ID not in data @@ -794,7 +834,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_stdio( +async def test_prompt_handler_in_memory( sentry_init, capture_events, capture_items, @@ -888,7 +928,7 @@ async def test_prompt(name, arguments): # Check span data assert data[SPANDATA.MCP_PROMPT_NAME] == "code_help" assert data[SPANDATA.MCP_METHOD_NAME] == "prompts/get" - assert data[SPANDATA.MCP_TRANSPORT] == "stdio" + assert SPANDATA.MCP_TRANSPORT not in data assert data[SPANDATA.MCP_REQUEST_ID] == "req-prompt" assert data["mcp.request.argument.language"] == "python" @@ -1112,7 +1152,7 @@ async def failing_prompt(name, arguments): @pytest.mark.asyncio @pytest.mark.parametrize("span_streaming", [True, False]) -async def test_resource_handler_stdio( +async def test_resource_handler_in_memory( sentry_init, capture_events, capture_items, span_streaming, stdio ): """Test that synchronous resource handlers create proper spans""" @@ -1200,7 +1240,7 @@ async def test_resource(uri): # Check span data assert data[SPANDATA.MCP_RESOURCE_URI] == "file:///path/to/file.txt" assert data[SPANDATA.MCP_METHOD_NAME] == "resources/read" - assert data[SPANDATA.MCP_TRANSPORT] == "stdio" + assert SPANDATA.MCP_TRANSPORT not in data assert data[SPANDATA.MCP_REQUEST_ID] == "req-resource" assert data[SPANDATA.MCP_RESOURCE_PROTOCOL] == "file" # Resources don't capture result content @@ -1873,10 +1913,16 @@ def test_tool_complex(tool_name, arguments): @pytest.mark.asyncio @pytest.mark.parametrize("span_streaming", [True, False]) @pytest.mark.skipif(IS_MCP_V2, reason="SSE scope propagation not supported in MCP v2") +@pytest.mark.parametrize("custom_transport", [False, True]) async def test_sse_transport_detection( - sentry_init, capture_events, capture_items, span_streaming, json_rpc_sse + sentry_init, + capture_events, + capture_items, + span_streaming, + json_rpc_sse, + custom_transport, ): - """Test that SSE transport is correctly detected via query parameter""" + """SSE records its concrete implementation and HTTP metadata.""" sentry_init( integrations=[MCPIntegration()], traces_sample_rate=1.0, @@ -1884,7 +1930,12 @@ async def test_sse_transport_detection( ) server = Server("test-server") - sse = SseServerTransport("/messages/") + + class CustomSSETransport(SseServerTransport): + pass + + transport_class = CustomSSETransport if custom_transport else SseServerTransport + sse = transport_class("/messages/") sse_connection_closed = asyncio.Event() @@ -1963,17 +2014,24 @@ async def test_tool(tool_name, arguments): tx = transactions[0] data = tx["spans"][0]["data"] - # Check that SSE transport is detected - assert data[SPANDATA.MCP_TRANSPORT] == "sse" - assert data[SPANDATA.NETWORK_TRANSPORT] == "tcp" + assert data[SPANDATA.MCP_TRANSPORT] == transport_class.__name__ + assert data[SPANDATA.NETWORK_PROTOCOL_NAME] == "http" + assert data[SPANDATA.NETWORK_PROTOCOL_VERSION] == "1.1" + assert SPANDATA.NETWORK_TRANSPORT not in data assert data[SPANDATA.MCP_SESSION_ID] == session_id @pytest.mark.asyncio @pytest.mark.parametrize("span_streaming", [True, False]) @pytest.mark.skipif(not IS_MCP_V2, reason="MCP v2 SSE transport detection") +@pytest.mark.parametrize("custom_transport", [False, True]) async def test_sse_transport_detection_v2( - sentry_init, capture_events, capture_items, span_streaming, json_rpc_sse + sentry_init, + capture_events, + capture_items, + span_streaming, + json_rpc_sse, + custom_transport, ): """Test that SSE transport is detected on MCP v2. @@ -1992,7 +2050,12 @@ async def test_sse_transport_detection_v2( ) server = Server("test-server") - sse = SseServerTransport("/messages/") + + class CustomSSETransport(SseServerTransport): + pass + + transport_class = CustomSSETransport if custom_transport else SseServerTransport + sse = transport_class("/messages/") sse_connection_closed = asyncio.Event() @@ -2078,8 +2141,10 @@ async def test_tool(ctx, params): break assert data is not None - assert data[SPANDATA.MCP_TRANSPORT] == "sse" - assert data[SPANDATA.NETWORK_TRANSPORT] == "tcp" + assert data[SPANDATA.MCP_TRANSPORT] == transport_class.__name__ + assert data[SPANDATA.NETWORK_PROTOCOL_NAME] == "http" + assert data[SPANDATA.NETWORK_PROTOCOL_VERSION] == "1.1" + assert SPANDATA.NETWORK_TRANSPORT not in data assert data[SPANDATA.MCP_SESSION_ID] == session_id @@ -2300,7 +2365,7 @@ async def test_tool(tool_name, arguments): # Non-sensitive identifying attributes are never gated assert data[SPANDATA.MCP_TOOL_NAME] == "calculate" assert data[SPANDATA.MCP_METHOD_NAME] == "tools/call" - assert data[SPANDATA.MCP_TRANSPORT] == "stdio" + assert SPANDATA.MCP_TRANSPORT not in data assert data[SPANDATA.MCP_REQUEST_ID] == "req-1" From bee91dc7591794ea51dc6b44b9f4d538b93f56ff Mon Sep 17 00:00:00 2001 From: betegon Date: Wed, 7 Oct 2026 20:34:23 +0200 Subject: [PATCH 2/2] test(mcp): align FastMCP transport attribute expectations --- tests/integrations/fastmcp/test_fastmcp.py | 54 +++++++++++++++------- 1 file changed, 37 insertions(+), 17 deletions(-) diff --git a/tests/integrations/fastmcp/test_fastmcp.py b/tests/integrations/fastmcp/test_fastmcp.py index 6b2dc3c643..510bf0f563 100644 --- a/tests/integrations/fastmcp/test_fastmcp.py +++ b/tests/integrations/fastmcp/test_fastmcp.py @@ -13,7 +13,7 @@ - Error capture and instrumentation - PII and include_prompts flag behavior - Request context data extraction -- Transport detection (stdio, http, sse) +- Transport metadata (in-memory, HTTP, SSE) All tests invoke tools/prompts/resources through the MCP Server's low-level request handlers (via CallToolRequest, GetPromptRequest, ReadResourceRequest) @@ -334,7 +334,7 @@ def add_numbers(a: int, b: int) -> dict: assert span["name"] == "tools/call add_numbers" assert span["attributes"][SPANDATA.MCP_TOOL_NAME] == "add_numbers" assert span["attributes"][SPANDATA.MCP_METHOD_NAME] == "tools/call" - assert span["attributes"][SPANDATA.MCP_TRANSPORT] == "stdio" + assert SPANDATA.MCP_TRANSPORT not in span["attributes"] assert span["attributes"][SPANDATA.MCP_REQUEST_ID] == "req-123" # Check PII-sensitive data @@ -367,7 +367,7 @@ def add_numbers(a: int, b: int) -> dict: assert span["description"] == "tools/call add_numbers" assert span["data"][SPANDATA.MCP_TOOL_NAME] == "add_numbers" assert span["data"][SPANDATA.MCP_METHOD_NAME] == "tools/call" - assert span["data"][SPANDATA.MCP_TRANSPORT] == "stdio" + assert SPANDATA.MCP_TRANSPORT not in span["data"] assert span["data"][SPANDATA.MCP_REQUEST_ID] == "req-123" # Check PII-sensitive data @@ -455,7 +455,10 @@ async def multiply_numbers(x: int, y: int) -> dict: assert span["name"] == "tools/call multiply_numbers" assert span["attributes"][SPANDATA.MCP_TOOL_NAME] == "multiply_numbers" assert span["attributes"][SPANDATA.MCP_METHOD_NAME] == "tools/call" - assert span["attributes"][SPANDATA.MCP_TRANSPORT] == "http" + assert ( + span["attributes"][SPANDATA.MCP_TRANSPORT] + == "StreamableHTTPServerTransport" + ) assert span["attributes"][SPANDATA.MCP_REQUEST_ID] == "req-456" assert span["attributes"][SPANDATA.MCP_SESSION_ID] == session_id @@ -495,7 +498,7 @@ async def multiply_numbers(x: int, y: int) -> dict: 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_TRANSPORT] == "StreamableHTTPServerTransport" assert span["data"][SPANDATA.MCP_REQUEST_ID] == "req-456" assert span["data"][SPANDATA.MCP_SESSION_ID] == session_id @@ -1319,7 +1322,10 @@ def sse_tool(value: str) -> dict: assert len(mcp_spans) >= 1 span = mcp_spans[0] # Check that SSE transport is detected - assert span["attributes"].get(SPANDATA.MCP_TRANSPORT) == "sse" + assert span["attributes"][SPANDATA.MCP_TRANSPORT] == "SseServerTransport" + assert span["attributes"][SPANDATA.NETWORK_PROTOCOL_NAME] == "http" + assert span["attributes"][SPANDATA.NETWORK_PROTOCOL_VERSION] == "1.1" + assert SPANDATA.NETWORK_TRANSPORT not in span["attributes"] else: events = capture_events() @@ -1355,7 +1361,10 @@ def sse_tool(value: str) -> dict: assert len(mcp_spans) >= 1 span = mcp_spans[0] # Check that SSE transport is detected - assert span["data"].get(SPANDATA.MCP_TRANSPORT) == "sse" + assert span["data"][SPANDATA.MCP_TRANSPORT] == "SseServerTransport" + assert span["data"][SPANDATA.NETWORK_PROTOCOL_NAME] == "http" + assert span["data"][SPANDATA.NETWORK_PROTOCOL_VERSION] == "1.1" + assert SPANDATA.NETWORK_TRANSPORT not in span["data"] @pytest.mark.parametrize("FastMCP", fastmcp_implementations, ids=fastmcp_ids) @@ -1423,7 +1432,13 @@ def http_tool(data: str) -> dict: span = spans[0] # Check that HTTP transport is detected - assert span["attributes"].get(SPANDATA.MCP_TRANSPORT) == "http" + assert ( + span["attributes"][SPANDATA.MCP_TRANSPORT] + == "StreamableHTTPServerTransport" + ) + assert span["attributes"][SPANDATA.NETWORK_PROTOCOL_NAME] == "http" + assert span["attributes"][SPANDATA.NETWORK_PROTOCOL_VERSION] == "1.1" + assert SPANDATA.NETWORK_TRANSPORT not in span["attributes"] else: events = capture_events() @@ -1450,13 +1465,16 @@ def http_tool(data: str) -> dict: span = tx["spans"][0] # Check that HTTP transport is detected - assert span["data"].get(SPANDATA.MCP_TRANSPORT) == "http" + assert span["data"][SPANDATA.MCP_TRANSPORT] == "StreamableHTTPServerTransport" + assert span["data"][SPANDATA.NETWORK_PROTOCOL_NAME] == "http" + assert span["data"][SPANDATA.NETWORK_PROTOCOL_VERSION] == "1.1" + assert SPANDATA.NETWORK_TRANSPORT not in span["data"] @pytest.mark.asyncio @pytest.mark.parametrize("FastMCP", fastmcp_implementations, ids=fastmcp_ids) @pytest.mark.parametrize("span_streaming", [True, False]) -async def test_fastmcp_stdio_transport( +async def test_fastmcp_in_memory_transport( sentry_init, capture_events, capture_items, @@ -1464,7 +1482,7 @@ async def test_fastmcp_stdio_transport( stdio, span_streaming, ): - """Test that FastMCP correctly detects stdio transport""" + """In-memory streams do not identify the MCP or network transport.""" sentry_init( integrations=[MCPIntegration()], traces_sample_rate=1.0, @@ -1500,9 +1518,10 @@ def stdio_tool(n: int) -> dict: assert len(mcp_spans) >= 1 span = mcp_spans[0] - # Check that stdio transport is detected - - assert span["attributes"].get(SPANDATA.MCP_TRANSPORT) == "stdio" + assert SPANDATA.MCP_TRANSPORT not in span["attributes"] + assert SPANDATA.NETWORK_TRANSPORT not in span["attributes"] + assert SPANDATA.NETWORK_PROTOCOL_NAME not in span["attributes"] + assert SPANDATA.NETWORK_PROTOCOL_VERSION not in span["attributes"] else: events = capture_events() with start_transaction(name="fastmcp tx"): @@ -1523,9 +1542,10 @@ def stdio_tool(n: int) -> dict: assert len(mcp_spans) >= 1 span = mcp_spans[0] - # Check that stdio transport is detected - - assert span["data"].get(SPANDATA.MCP_TRANSPORT) == "stdio" + assert SPANDATA.MCP_TRANSPORT not in span["data"] + assert SPANDATA.NETWORK_TRANSPORT not in span["data"] + assert SPANDATA.NETWORK_PROTOCOL_NAME not in span["data"] + assert SPANDATA.NETWORK_PROTOCOL_VERSION not in span["data"] @pytest.mark.asyncio