From 4c49c1af5b535ebe105a7c0454c4861e30f4b07c Mon Sep 17 00:00:00 2001 From: Alexander Alderman Webb Date: Mon, 12 Jan 2026 10:09:59 +0100 Subject: [PATCH 01/11] fix(mcp): Nest MCP spans under HTTP transactions --- sentry_sdk/integrations/mcp.py | 145 +++++++++++------ sentry_sdk/scope.py | 13 +- tests/integrations/mcp/test_mcp.py | 247 +++++++++++++++++++++++------ 3 files changed, 311 insertions(+), 94 deletions(-) diff --git a/sentry_sdk/integrations/mcp.py b/sentry_sdk/integrations/mcp.py index 47fda272b7..b92f4a1924 100644 --- a/sentry_sdk/integrations/mcp.py +++ b/sentry_sdk/integrations/mcp.py @@ -21,6 +21,7 @@ try: from mcp.server.lowlevel import Server # type: ignore[import-not-found] from mcp.server.lowlevel.server import request_ctx # type: ignore[import-not-found] + from mcp.server.streamable_http import StreamableHTTPServerTransport except ImportError: raise DidNotEnable("MCP SDK not installed") @@ -33,6 +34,8 @@ if TYPE_CHECKING: from typing import Any, Callable, Optional + from starlette.types import Receive, Scope, Send + class MCPIntegration(Integration): identifier = "mcp" @@ -54,11 +57,32 @@ def setup_once() -> None: Patches MCP server classes to instrument handler execution. """ _patch_lowlevel_server() + _patch_handle_request() if FastMCP is not None: _patch_fastmcp() +def _get_active_http_scopes(): + try: + ctx = request_ctx.get() + except LookupError: + return None + + if ( + ctx is None + or not hasattr(ctx, "request") + or ctx.request is None + or "state" not in ctx.request.scope + ): + return None + + return ( + ctx.request.scope["state"].get("sentry_sdk.current_scope"), + ctx.request.scope["state"].get("sentry_sdk.isolation_scope"), + ) + + def _get_request_context_data() -> "tuple[Optional[str], Optional[str], str]": """ Extract request ID, session ID, and MCP transport type from the request context. @@ -381,56 +405,67 @@ async def _async_handler_wrapper( result_data_key, ) = _prepare_handler_data(handler_type, original_args, original_kwargs) - # Start span and execute - with get_start_span_function()( - op=OP.MCP_SERVER, - name=span_name, - origin=MCPIntegration.origin, - ) as span: - # Get request ID, session ID, and transport from context - request_id, session_id, mcp_transport = _get_request_context_data() + scopes = _get_active_http_scopes() - # Set input span data - _set_span_input_data( - span, - handler_name, - span_data_key, - mcp_method_name, - arguments, - request_id, - session_id, - mcp_transport, - ) + if scopes is None: + current_scope = None + isolation_scope = None + else: + current_scope, isolation_scope = scopes - # For resources, extract and set protocol - if handler_type == "resource": - if original_args: - uri = original_args[0] - else: - uri = original_kwargs.get("uri") + # Get request ID, session ID, and transport from context + request_id, session_id, mcp_transport = _get_request_context_data() - protocol = None - if hasattr(uri, "scheme"): - protocol = uri.scheme - elif handler_name and "://" in handler_name: - protocol = handler_name.split("://")[0] - if protocol: - span.set_data(SPANDATA.MCP_RESOURCE_PROTOCOL, protocol) - - try: - # Execute the async handler - if self is not None: - original_args = (self, *original_args) - result = await func(*original_args, **original_kwargs) - except Exception as e: - # Set error flag for tools - if handler_type == "tool": - span.set_data(SPANDATA.MCP_TOOL_RESULT_IS_ERROR, True) - sentry_sdk.capture_exception(e) - raise + # Start span and execute + with sentry_sdk.scope.use_isolation_scope(isolation_scope): + with sentry_sdk.scope.use_scope(current_scope): + with sentry_sdk.start_span( + op=OP.MCP_SERVER, + name=span_name, + origin=MCPIntegration.origin, + ) as span: + # Set input span data + _set_span_input_data( + span, + handler_name, + span_data_key, + mcp_method_name, + arguments, + request_id, + session_id, + mcp_transport, + ) + + # For resources, extract and set protocol + if handler_type == "resource": + if original_args: + uri = original_args[0] + else: + uri = original_kwargs.get("uri") + + protocol = None + if hasattr(uri, "scheme"): + protocol = uri.scheme + elif handler_name and "://" in handler_name: + protocol = handler_name.split("://")[0] + if protocol: + span.set_data(SPANDATA.MCP_RESOURCE_PROTOCOL, protocol) + + try: + # Execute the async handler + if self is not None: + original_args = (self, *original_args) + result = await func(*original_args, **original_kwargs) + except Exception as e: + # Set error flag for tools + if handler_type == "tool": + span.set_data(SPANDATA.MCP_TOOL_RESULT_IS_ERROR, True) + sentry_sdk.capture_exception(e) + raise + + _set_span_output_data(span, result, result_data_key, handler_type) - _set_span_output_data(span, result, result_data_key, handler_type) - return result + return result def _sync_handler_wrapper( @@ -618,6 +653,24 @@ def patched_read_resource( Server.read_resource = patched_read_resource +def _patch_handle_request(): + original_handle_request = StreamableHTTPServerTransport.handle_request + + @wraps(original_handle_request) + async def patched_handle_request( + self, scope: "Scope", receive: "Receive", send: "Send" + ) -> None: + scope.setdefault("state", {})["sentry_sdk.isolation_scope"] = ( + sentry_sdk.get_current_scope() + ) + scope.setdefault("state", {})["sentry_sdk.current_scope"] = ( + sentry_sdk.get_current_scope() + ) + await original_handle_request(self, scope, receive, send) + + StreamableHTTPServerTransport.handle_request = patched_handle_request + + def _patch_fastmcp() -> None: """ Patches the standalone fastmcp package's FastMCP class. diff --git a/sentry_sdk/scope.py b/sentry_sdk/scope.py index 6df26690c8..1e401dcfac 100644 --- a/sentry_sdk/scope.py +++ b/sentry_sdk/scope.py @@ -100,6 +100,7 @@ F = TypeVar("F", bound=Callable[..., Any]) T = TypeVar("T") + S = TypeVar("S", bound=Optional["Scope"]) # Holds data that will be added to **all** events sent by this process. @@ -1786,7 +1787,7 @@ def new_scope() -> "Generator[Scope, None, None]": @contextmanager -def use_scope(scope: "Scope") -> "Generator[Scope, None, None]": +def use_scope(scope: "S") -> "Generator[S, None, None]": """ .. versionadded:: 2.0.0 @@ -1808,6 +1809,10 @@ def use_scope(scope: "Scope") -> "Generator[Scope, None, None]": sentry_sdk.capture_message("hello, again") # will NOT include `color` tag. """ + if scope is None: + yield scope + return + # set given scope as current scope token = _current_scope.set(scope) @@ -1871,7 +1876,7 @@ def isolation_scope() -> "Generator[Scope, None, None]": @contextmanager -def use_isolation_scope(isolation_scope: "Scope") -> "Generator[Scope, None, None]": +def use_isolation_scope(isolation_scope: "S") -> "Generator[S, None, None]": """ .. versionadded:: 2.0.0 @@ -1892,6 +1897,10 @@ def use_isolation_scope(isolation_scope: "Scope") -> "Generator[Scope, None, Non sentry_sdk.capture_message("hello, again") # will NOT include `color` tag. """ + if isolation_scope is None: + yield isolation_scope + return + # fork current scope current_scope = Scope.get_current_scope() forked_current_scope = current_scope.fork() diff --git a/tests/integrations/mcp/test_mcp.py b/tests/integrations/mcp/test_mcp.py index 4415467cd7..005652e57c 100644 --- a/tests/integrations/mcp/test_mcp.py +++ b/tests/integrations/mcp/test_mcp.py @@ -28,8 +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.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 @@ -41,6 +48,49 @@ async def __call__(self, *args, **kwargs): from sentry_sdk.integrations.mcp import MCPIntegration +def json_rpc(app, method: str, params, request_id: str | None = None): + if request_id is None: + request_id = "2" # arbitrary + + 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"] + + 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 + + @pytest.fixture(autouse=True) def reset_request_ctx(): """Reset request context before and after each test""" @@ -221,22 +271,49 @@ 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( + debug=True, + 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"} + return [ + TextContent( + type="text", + text=json.dumps({"status": "completed"}), + ) + ] - with start_transaction(name="mcp tx"): - result = await test_tool_async("process", {"data": "test"}) + session_id, result = json_rpc( + app, + method="tools/call", + params={ + "name": "process", + "arguments": { + "data": "test", + }, + }, + request_id="req-456", + ) + assert not result.json()["result"]["isError"] - assert result == {"status": "completed"} + http_requests_in_mcp_session = [ + transaction + for transaction in events + if "mcp-session-id" in transaction["request"]["headers"] + ] + assert len(http_requests_in_mcp_session) == 1 + tx = http_requests_in_mcp_session[0] - (tx,) = events assert tx["type"] == "transaction" assert len(tx["spans"]) == 1 @@ -250,13 +327,16 @@ async def test_tool_async(tool_name, arguments): 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 assert span["data"]["mcp.request.argument.data"] == '"test"' # Check PII-sensitive data if send_default_pii and include_prompts: + # TODO: Investigate why tool result is double-serialized. assert span["data"][SPANDATA.MCP_TOOL_RESULT_CONTENT] == json.dumps( - {"status": "completed"} + json.dumps( + {"status": "completed"}, + ) ) else: assert SPANDATA.MCP_TOOL_RESULT_CONTENT not in span["data"] @@ -385,27 +465,54 @@ 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( + debug=True, + 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", {}) + _, result = json_rpc( + app, + method="prompts/get", + params={ + "name": "mcp_info", + "arguments": {}, + }, + ) + assert len(result.json()["result"]["messages"]) == 2 - assert len(result.messages) == 2 + http_requests_in_mcp_session = [ + transaction + for transaction in events + if "mcp-session-id" in transaction["request"]["headers"] + ] + assert len(http_requests_in_mcp_session) == 1 + tx = http_requests_in_mcp_session[0] - (tx,) = events assert tx["type"] == "transaction" assert len(tx["spans"]) == 1 @@ -504,23 +611,47 @@ 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( + debug=True, + 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", + }, + ) - assert result["data"] == "resource data" + http_requests_in_mcp_session = [ + transaction + for transaction in events + if "mcp-session-id" in transaction["request"]["headers"] + ] + assert len(http_requests_in_mcp_session) == 1 + tx = http_requests_in_mcp_session[0] + + assert result.json()["result"]["contents"][0]["text"] == json.dumps( + {"data": "resource data"} + ) - (tx,) = events assert tx["type"] == "transaction" assert len(tx["spans"]) == 1 @@ -530,7 +661,7 @@ async def test_resource_async(uri): 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 span["data"][SPANDATA.MCP_SESSION_ID] == session_id def test_resource_handler_with_error(sentry_init, capture_events): @@ -964,28 +1095,52 @@ 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, + ) + + app = Starlette( + debug=True, + routes=[ + Mount("/mcp", app=session_manager.handle_request), + ], + lifespan=lambda app: session_manager.run(), ) - request_ctx.set(mock_ctx) @server.call_tool() - def test_tool(tool_name, arguments): - return {"result": "success"} + async def test_tool(tool_name, arguments): + return [ + TextContent( + type="text", + text=json.dumps({"status": "success"}), + ) + ] - with start_transaction(name="mcp tx"): - result = test_tool("http_tool", {}) + _, result = json_rpc( + app, + method="tools/call", + params={ + "name": "http_tool", + "arguments": {}, + }, + ) + assert not result.json()["result"]["isError"] - assert result == {"result": "success"} + http_requests_in_mcp_session = [ + transaction + for transaction in events + if "mcp-session-id" in transaction["request"]["headers"] + ] + assert len(http_requests_in_mcp_session) == 1 + tx = http_requests_in_mcp_session[0] - (tx,) = events span = tx["spans"][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 len(span["data"][SPANDATA.MCP_SESSION_ID]) == 32 def test_stdio_transport_detection(sentry_init, capture_events): From a34ce9ddbbd7ab075f69969bcd60c2f80f1f9e1e Mon Sep 17 00:00:00 2001 From: Alexander Alderman Webb Date: Mon, 12 Jan 2026 10:12:21 +0100 Subject: [PATCH 02/11] isolation scope instead of current scope --- sentry_sdk/integrations/mcp.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sentry_sdk/integrations/mcp.py b/sentry_sdk/integrations/mcp.py index b92f4a1924..f45f48c34b 100644 --- a/sentry_sdk/integrations/mcp.py +++ b/sentry_sdk/integrations/mcp.py @@ -661,7 +661,7 @@ async def patched_handle_request( self, scope: "Scope", receive: "Receive", send: "Send" ) -> None: scope.setdefault("state", {})["sentry_sdk.isolation_scope"] = ( - sentry_sdk.get_current_scope() + sentry_sdk.get_isolation_scope() ) scope.setdefault("state", {})["sentry_sdk.current_scope"] = ( sentry_sdk.get_current_scope() From c994d0f5ceb0e70a54b003a43283a39bcd9373c9 Mon Sep 17 00:00:00 2001 From: Alexander Alderman Webb Date: Mon, 12 Jan 2026 10:23:07 +0100 Subject: [PATCH 03/11] remove debug from Starlette --- tests/integrations/mcp/test_mcp.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/tests/integrations/mcp/test_mcp.py b/tests/integrations/mcp/test_mcp.py index 005652e57c..15177d1f6f 100644 --- a/tests/integrations/mcp/test_mcp.py +++ b/tests/integrations/mcp/test_mcp.py @@ -277,7 +277,6 @@ async def test_tool_handler_async( ) app = Starlette( - debug=True, routes=[ Mount("/mcp", app=session_manager.handle_request), ], @@ -471,7 +470,6 @@ async def test_prompt_handler_async( ) app = Starlette( - debug=True, routes=[ Mount("/mcp", app=session_manager.handle_request), ], @@ -617,7 +615,6 @@ async def test_resource_handler_async(sentry_init, capture_events): ) app = Starlette( - debug=True, routes=[ Mount("/mcp", app=session_manager.handle_request), ], @@ -1101,7 +1098,6 @@ def test_streamable_http_transport_detection(sentry_init, capture_events): ) app = Starlette( - debug=True, routes=[ Mount("/mcp", app=session_manager.handle_request), ], From f4c30c208db71ebf5df24be94d8eb18c6870f375 Mon Sep 17 00:00:00 2001 From: Alexander Alderman Webb Date: Mon, 12 Jan 2026 10:26:26 +0100 Subject: [PATCH 04/11] Add scope to request mock in FastMCP tests --- tests/integrations/fastmcp/test_fastmcp.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/integrations/fastmcp/test_fastmcp.py b/tests/integrations/fastmcp/test_fastmcp.py index ef2a1f9cb7..4f2d0e6916 100644 --- a/tests/integrations/fastmcp/test_fastmcp.py +++ b/tests/integrations/fastmcp/test_fastmcp.py @@ -262,6 +262,7 @@ class MockHTTPRequest: def __init__(self, session_id=None, transport="http"): self.headers = {} self.query_params = {} + self.scope = {} if transport == "sse": # SSE transport uses query parameter From 3f13802ae2063565a6619ff5b1495720405fbc56 Mon Sep 17 00:00:00 2001 From: Alexander Alderman Webb Date: Mon, 12 Jan 2026 11:24:18 +0100 Subject: [PATCH 05/11] send notification response --- tests/integrations/mcp/test_mcp.py | 70 +++++++++++++++++------------- 1 file changed, 41 insertions(+), 29 deletions(-) diff --git a/tests/integrations/mcp/test_mcp.py b/tests/integrations/mcp/test_mcp.py index 15177d1f6f..0c6b95b391 100644 --- a/tests/integrations/mcp/test_mcp.py +++ b/tests/integrations/mcp/test_mcp.py @@ -50,7 +50,7 @@ async def __call__(self, *args, **kwargs): def json_rpc(app, method: str, params, request_id: str | None = None): if request_id is None: - request_id = "2" # arbitrary + request_id = "1" # arbitrary with TestClient(app) as client: init_response = client.post( @@ -73,6 +73,22 @@ def json_rpc(app, method: str, params, request_id: str | None = None): 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={ @@ -91,6 +107,18 @@ def json_rpc(app, method: str, params, request_id: str | None = None): return session_id, response +def select_transactions_with_mcp_spans(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", []) + ) + ] + + @pytest.fixture(autouse=True) def reset_request_ctx(): """Reset request context before and after each test""" @@ -305,13 +333,9 @@ async def test_tool_async(tool_name, arguments): ) assert not result.json()["result"]["isError"] - http_requests_in_mcp_session = [ - transaction - for transaction in events - if "mcp-session-id" in transaction["request"]["headers"] - ] - assert len(http_requests_in_mcp_session) == 1 - tx = http_requests_in_mcp_session[0] + transactions = select_transactions_with_mcp_spans(events, "tools/call") + assert len(transactions) == 1 + tx = transactions[0] assert tx["type"] == "transaction" assert len(tx["spans"]) == 1 @@ -503,13 +527,9 @@ async def test_prompt_async(name, arguments): ) assert len(result.json()["result"]["messages"]) == 2 - http_requests_in_mcp_session = [ - transaction - for transaction in events - if "mcp-session-id" in transaction["request"]["headers"] - ] - assert len(http_requests_in_mcp_session) == 1 - tx = http_requests_in_mcp_session[0] + transactions = select_transactions_with_mcp_spans(events, "prompts/get") + assert len(transactions) == 1 + tx = transactions[0] assert tx["type"] == "transaction" assert len(tx["spans"]) == 1 @@ -637,13 +657,9 @@ async def test_resource_async(uri): }, ) - http_requests_in_mcp_session = [ - transaction - for transaction in events - if "mcp-session-id" in transaction["request"]["headers"] - ] - assert len(http_requests_in_mcp_session) == 1 - tx = http_requests_in_mcp_session[0] + transactions = select_transactions_with_mcp_spans(events, "resources/read") + assert len(transactions) == 1 + tx = transactions[0] assert result.json()["result"]["contents"][0]["text"] == json.dumps( {"data": "resource data"} @@ -1123,13 +1139,9 @@ async def test_tool(tool_name, arguments): ) assert not result.json()["result"]["isError"] - http_requests_in_mcp_session = [ - transaction - for transaction in events - if "mcp-session-id" in transaction["request"]["headers"] - ] - assert len(http_requests_in_mcp_session) == 1 - tx = http_requests_in_mcp_session[0] + transactions = select_transactions_with_mcp_spans(events, "tools/call") + assert len(transactions) == 1 + tx = transactions[0] span = tx["spans"][0] From 98d9929cf0d1f6bd2316157844ebc6dbf49b8832 Mon Sep 17 00:00:00 2001 From: Alexander Alderman Webb Date: Mon, 12 Jan 2026 11:27:42 +0100 Subject: [PATCH 06/11] mypy --- sentry_sdk/integrations/mcp.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/sentry_sdk/integrations/mcp.py b/sentry_sdk/integrations/mcp.py index f45f48c34b..005574afa5 100644 --- a/sentry_sdk/integrations/mcp.py +++ b/sentry_sdk/integrations/mcp.py @@ -21,7 +21,7 @@ try: from mcp.server.lowlevel import Server # type: ignore[import-not-found] from mcp.server.lowlevel.server import request_ctx # type: ignore[import-not-found] - from mcp.server.streamable_http import StreamableHTTPServerTransport + from mcp.server.streamable_http import StreamableHTTPServerTransport # type: ignore[import-not-found] except ImportError: raise DidNotEnable("MCP SDK not installed") @@ -32,7 +32,7 @@ if TYPE_CHECKING: - from typing import Any, Callable, Optional + from typing import Any, Callable, Optional, Tuple from starlette.types import Receive, Scope, Send @@ -63,7 +63,9 @@ def setup_once() -> None: _patch_fastmcp() -def _get_active_http_scopes(): +def _get_active_http_scopes() -> ( + "Optional[Tuple[Optional[sentry_sdk.tracing.Scope], Optional[sentry_sdk.tracing.Scope]]]" +): try: ctx = request_ctx.get() except LookupError: From 3e016e9f578302ee35a3e6851ee3e9fe835943a1 Mon Sep 17 00:00:00 2001 From: Alexander Alderman Webb Date: Mon, 12 Jan 2026 11:37:16 +0100 Subject: [PATCH 07/11] mypy --- sentry_sdk/integrations/mcp.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/sentry_sdk/integrations/mcp.py b/sentry_sdk/integrations/mcp.py index 005574afa5..04ec92f637 100644 --- a/sentry_sdk/integrations/mcp.py +++ b/sentry_sdk/integrations/mcp.py @@ -64,7 +64,7 @@ def setup_once() -> None: def _get_active_http_scopes() -> ( - "Optional[Tuple[Optional[sentry_sdk.tracing.Scope], Optional[sentry_sdk.tracing.Scope]]]" + "Optional[Tuple[Optional[sentry_sdk.Scope], Optional[sentry_sdk.Scope]]]" ): try: ctx = request_ctx.get() @@ -655,12 +655,15 @@ def patched_read_resource( Server.read_resource = patched_read_resource -def _patch_handle_request(): +def _patch_handle_request() -> None: original_handle_request = StreamableHTTPServerTransport.handle_request @wraps(original_handle_request) async def patched_handle_request( - self, scope: "Scope", receive: "Receive", send: "Send" + self: "StreamableHTTPServerTransport", + scope: "Scope", + receive: "Receive", + send: "Send", ) -> None: scope.setdefault("state", {})["sentry_sdk.isolation_scope"] = ( sentry_sdk.get_isolation_scope() From 1b1cec5cfdb52fd2c1dca797fdec131087e7026b Mon Sep 17 00:00:00 2001 From: Alexander Alderman Webb Date: Mon, 12 Jan 2026 11:44:32 +0100 Subject: [PATCH 08/11] mypy again --- sentry_sdk/integrations/mcp.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sentry_sdk/integrations/mcp.py b/sentry_sdk/integrations/mcp.py index 04ec92f637..890446c90b 100644 --- a/sentry_sdk/integrations/mcp.py +++ b/sentry_sdk/integrations/mcp.py @@ -34,7 +34,7 @@ if TYPE_CHECKING: from typing import Any, Callable, Optional, Tuple - from starlette.types import Receive, Scope, Send + from starlette.types import Receive, Scope, Send # type: ignore[import-not-found] class MCPIntegration(Integration): From 31d32b76676d7a23a66df15c7412734f144ae3bc Mon Sep 17 00:00:00 2001 From: Alexander Alderman Webb Date: Mon, 12 Jan 2026 15:00:00 +0100 Subject: [PATCH 09/11] restore previous assertions --- tests/integrations/mcp/test_mcp.py | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/tests/integrations/mcp/test_mcp.py b/tests/integrations/mcp/test_mcp.py index 0c6b95b391..cb49e4c895 100644 --- a/tests/integrations/mcp/test_mcp.py +++ b/tests/integrations/mcp/test_mcp.py @@ -331,7 +331,9 @@ async def test_tool_async(tool_name, arguments): }, request_id="req-456", ) - assert not result.json()["result"]["isError"] + assert result.json()["result"]["content"][0]["text"] == json.dumps( + {"status": "completed"} + ) transactions = select_transactions_with_mcp_spans(events, "tools/call") assert len(transactions) == 1 @@ -657,14 +659,14 @@ async def test_resource_async(uri): }, ) - transactions = select_transactions_with_mcp_spans(events, "resources/read") - assert len(transactions) == 1 - tx = transactions[0] - assert result.json()["result"]["contents"][0]["text"] == json.dumps( {"data": "resource data"} ) + transactions = select_transactions_with_mcp_spans(events, "resources/read") + assert len(transactions) == 1 + tx = transactions[0] + assert tx["type"] == "transaction" assert len(tx["spans"]) == 1 @@ -1137,7 +1139,9 @@ async def test_tool(tool_name, arguments): "arguments": {}, }, ) - assert not result.json()["result"]["isError"] + assert result.json()["result"]["content"][0]["text"] == json.dumps( + {"status": "success"} + ) transactions = select_transactions_with_mcp_spans(events, "tools/call") assert len(transactions) == 1 From 784f57ac3ea1184a652f9b57dada4a1dcb9311b8 Mon Sep 17 00:00:00 2001 From: Alexander Alderman Webb Date: Tue, 13 Jan 2026 11:45:26 +0100 Subject: [PATCH 10/11] remove redundant setdefault --- sentry_sdk/integrations/mcp.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/sentry_sdk/integrations/mcp.py b/sentry_sdk/integrations/mcp.py index 890446c90b..f0605b7899 100644 --- a/sentry_sdk/integrations/mcp.py +++ b/sentry_sdk/integrations/mcp.py @@ -668,9 +668,7 @@ async def patched_handle_request( scope.setdefault("state", {})["sentry_sdk.isolation_scope"] = ( sentry_sdk.get_isolation_scope() ) - scope.setdefault("state", {})["sentry_sdk.current_scope"] = ( - sentry_sdk.get_current_scope() - ) + scope["state"]["sentry_sdk.current_scope"] = sentry_sdk.get_current_scope() await original_handle_request(self, scope, receive, send) StreamableHTTPServerTransport.handle_request = patched_handle_request From 0e95d7f3ffd627dd1e5264a36c61e2e262f7a353 Mon Sep 17 00:00:00 2001 From: Alexander Alderman Webb Date: Tue, 13 Jan 2026 13:17:50 +0100 Subject: [PATCH 11/11] . --- sentry_sdk/integrations/mcp.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sentry_sdk/integrations/mcp.py b/sentry_sdk/integrations/mcp.py index f0605b7899..b03926c6da 100644 --- a/sentry_sdk/integrations/mcp.py +++ b/sentry_sdk/integrations/mcp.py @@ -421,7 +421,7 @@ async def _async_handler_wrapper( # Start span and execute with sentry_sdk.scope.use_isolation_scope(isolation_scope): with sentry_sdk.scope.use_scope(current_scope): - with sentry_sdk.start_span( + with get_start_span_function()( op=OP.MCP_SERVER, name=span_name, origin=MCPIntegration.origin,