Skip to content

Commit 3bb163a

Browse files
fix(mcp): Nest MCP spans under HTTP transactions (#5292)
Avoid scope context loss in `MCPIntegration` when using the Streamable HTTP transport. Patch `StreamableHTTPServerTransport.handle_request()` to store isolation and current scopes on the Starlette request object's `scope` attribute. Run MCP handlers in the context of the stored scopes, if these are available. The Python MCP SDK uses an in-memory queue of requests. The HTTP propagation context is not active in the MCP decorators we patch, since they run after requests are retrieved from the queue.
1 parent bf0a683 commit 3bb163a

File tree

4 files changed

+211
-112
lines changed

4 files changed

+211
-112
lines changed

sentry_sdk/integrations/mcp.py

Lines changed: 123 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,12 @@
1717
from sentry_sdk.integrations import Integration, DidNotEnable
1818
from sentry_sdk.utils import safe_serialize
1919
from sentry_sdk.scope import should_send_default_pii
20+
from sentry_sdk.integrations._wsgi_common import nullcontext
2021

2122
try:
2223
from mcp.server.lowlevel import Server # type: ignore[import-not-found]
2324
from mcp.server.lowlevel.server import request_ctx # type: ignore[import-not-found]
25+
from mcp.server.streamable_http import StreamableHTTPServerTransport # type: ignore[import-not-found]
2426
except ImportError:
2527
raise DidNotEnable("MCP SDK not installed")
2628

@@ -31,7 +33,9 @@
3133

3234

3335
if TYPE_CHECKING:
34-
from typing import Any, Callable, Optional
36+
from typing import Any, Callable, Optional, Tuple, ContextManager
37+
38+
from starlette.types import Receive, Scope, Send # type: ignore[import-not-found]
3539

3640

3741
class MCPIntegration(Integration):
@@ -54,11 +58,34 @@ def setup_once() -> None:
5458
Patches MCP server classes to instrument handler execution.
5559
"""
5660
_patch_lowlevel_server()
61+
_patch_handle_request()
5762

5863
if FastMCP is not None:
5964
_patch_fastmcp()
6065

6166

67+
def _get_active_http_scopes() -> (
68+
"Optional[Tuple[Optional[sentry_sdk.Scope], Optional[sentry_sdk.Scope]]]"
69+
):
70+
try:
71+
ctx = request_ctx.get()
72+
except LookupError:
73+
return None
74+
75+
if (
76+
ctx is None
77+
or not hasattr(ctx, "request")
78+
or ctx.request is None
79+
or "state" not in ctx.request.scope
80+
):
81+
return None
82+
83+
return (
84+
ctx.request.scope["state"].get("sentry_sdk.isolation_scope"),
85+
ctx.request.scope["state"].get("sentry_sdk.current_scope"),
86+
)
87+
88+
6289
def _get_request_context_data() -> "tuple[Optional[str], Optional[str], str]":
6390
"""
6491
Extract request ID, session ID, and MCP transport type from the request context.
@@ -382,60 +409,85 @@ async def _handler_wrapper(
382409
result_data_key,
383410
) = _prepare_handler_data(handler_type, original_args, original_kwargs)
384411

385-
# Start span and execute
386-
with get_start_span_function()(
387-
op=OP.MCP_SERVER,
388-
name=span_name,
389-
origin=MCPIntegration.origin,
390-
) as span:
391-
# Get request ID, session ID, and transport from context
392-
request_id, session_id, mcp_transport = _get_request_context_data()
393-
394-
# Set input span data
395-
_set_span_input_data(
396-
span,
397-
handler_name,
398-
span_data_key,
399-
mcp_method_name,
400-
arguments,
401-
request_id,
402-
session_id,
403-
mcp_transport,
412+
scopes = _get_active_http_scopes()
413+
414+
isolation_scope_context: "ContextManager[Any]"
415+
current_scope_context: "ContextManager[Any]"
416+
417+
if scopes is None:
418+
isolation_scope_context = nullcontext()
419+
current_scope_context = nullcontext()
420+
else:
421+
isolation_scope, current_scope = scopes
422+
423+
isolation_scope_context = (
424+
nullcontext()
425+
if isolation_scope is None
426+
else sentry_sdk.scope.use_isolation_scope(isolation_scope)
427+
)
428+
current_scope_context = (
429+
nullcontext()
430+
if current_scope is None
431+
else sentry_sdk.scope.use_scope(current_scope)
404432
)
405433

406-
# For resources, extract and set protocol
407-
if handler_type == "resource":
408-
if original_args:
409-
uri = original_args[0]
410-
else:
411-
uri = original_kwargs.get("uri")
412-
413-
protocol = None
414-
if hasattr(uri, "scheme"):
415-
protocol = uri.scheme
416-
elif handler_name and "://" in handler_name:
417-
protocol = handler_name.split("://")[0]
418-
if protocol:
419-
span.set_data(SPANDATA.MCP_RESOURCE_PROTOCOL, protocol)
434+
# Get request ID, session ID, and transport from context
435+
request_id, session_id, mcp_transport = _get_request_context_data()
420436

421-
try:
422-
# Execute the async handler
423-
if self is not None:
424-
original_args = (self, *original_args)
425-
426-
result = func(*original_args, **original_kwargs)
427-
if force_await or inspect.isawaitable(result):
428-
result = await result
429-
430-
except Exception as e:
431-
# Set error flag for tools
432-
if handler_type == "tool":
433-
span.set_data(SPANDATA.MCP_TOOL_RESULT_IS_ERROR, True)
434-
sentry_sdk.capture_exception(e)
435-
raise
436-
437-
_set_span_output_data(span, result, result_data_key, handler_type)
438-
return result
437+
# Start span and execute
438+
with isolation_scope_context:
439+
with current_scope_context:
440+
with get_start_span_function()(
441+
op=OP.MCP_SERVER,
442+
name=span_name,
443+
origin=MCPIntegration.origin,
444+
) as span:
445+
# Set input span data
446+
_set_span_input_data(
447+
span,
448+
handler_name,
449+
span_data_key,
450+
mcp_method_name,
451+
arguments,
452+
request_id,
453+
session_id,
454+
mcp_transport,
455+
)
456+
457+
# For resources, extract and set protocol
458+
if handler_type == "resource":
459+
if original_args:
460+
uri = original_args[0]
461+
else:
462+
uri = original_kwargs.get("uri")
463+
464+
protocol = None
465+
if hasattr(uri, "scheme"):
466+
protocol = uri.scheme
467+
elif handler_name and "://" in handler_name:
468+
protocol = handler_name.split("://")[0]
469+
if protocol:
470+
span.set_data(SPANDATA.MCP_RESOURCE_PROTOCOL, protocol)
471+
472+
try:
473+
# Execute the async handler
474+
if self is not None:
475+
original_args = (self, *original_args)
476+
477+
result = func(*original_args, **original_kwargs)
478+
if force_await or inspect.isawaitable(result):
479+
result = await result
480+
481+
except Exception as e:
482+
# Set error flag for tools
483+
if handler_type == "tool":
484+
span.set_data(SPANDATA.MCP_TOOL_RESULT_IS_ERROR, True)
485+
sentry_sdk.capture_exception(e)
486+
raise
487+
488+
_set_span_output_data(span, result, result_data_key, handler_type)
489+
490+
return result
439491

440492

441493
def _create_instrumented_decorator(
@@ -521,6 +573,25 @@ def patched_read_resource(
521573
Server.read_resource = patched_read_resource
522574

523575

576+
def _patch_handle_request() -> None:
577+
original_handle_request = StreamableHTTPServerTransport.handle_request
578+
579+
@wraps(original_handle_request)
580+
async def patched_handle_request(
581+
self: "StreamableHTTPServerTransport",
582+
scope: "Scope",
583+
receive: "Receive",
584+
send: "Send",
585+
) -> None:
586+
scope.setdefault("state", {})["sentry_sdk.isolation_scope"] = (
587+
sentry_sdk.get_isolation_scope()
588+
)
589+
scope["state"]["sentry_sdk.current_scope"] = sentry_sdk.get_current_scope()
590+
await original_handle_request(self, scope, receive, send)
591+
592+
StreamableHTTPServerTransport.handle_request = patched_handle_request
593+
594+
524595
def _patch_fastmcp() -> None:
525596
"""
526597
Patches the standalone fastmcp package's FastMCP class.

tests/conftest.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -804,6 +804,22 @@ def inner(events):
804804
return inner
805805

806806

807+
@pytest.fixture()
808+
def select_transactions_with_mcp_spans():
809+
def inner(events, method_name):
810+
return [
811+
transaction
812+
for transaction in events
813+
if transaction["type"] == "transaction"
814+
and any(
815+
span["data"].get("mcp.method.name") == method_name
816+
for span in transaction.get("spans", [])
817+
)
818+
]
819+
820+
return inner
821+
822+
807823
@pytest.fixture()
808824
def json_rpc_sse():
809825
class StreamingASGITransport(ASGITransport):

tests/integrations/fastmcp/test_fastmcp.py

Lines changed: 31 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -343,7 +343,7 @@ async def test_fastmcp_tool_async(
343343
send_default_pii,
344344
include_prompts,
345345
json_rpc,
346-
select_mcp_transactions,
346+
select_transactions_with_mcp_spans,
347347
):
348348
"""Test that FastMCP async tool handlers create proper spans"""
349349
sentry_init(
@@ -387,24 +387,26 @@ async def multiply_numbers(x: int, y: int) -> dict:
387387
"operation": "multiplication",
388388
}
389389

390-
transactions = select_mcp_transactions(events)
390+
transactions = select_transactions_with_mcp_spans(events, method_name="tools/call")
391391
assert len(transactions) == 1
392392
tx = transactions[0]
393+
assert len(tx["spans"]) == 1
394+
span = tx["spans"][0]
393395

394-
assert tx["contexts"]["trace"]["op"] == OP.MCP_SERVER
395-
assert tx["contexts"]["trace"]["origin"] == "auto.ai.mcp"
396-
assert tx["transaction"] == "tools/call multiply_numbers"
397-
assert tx["contexts"]["trace"]["data"][SPANDATA.MCP_TOOL_NAME] == "multiply_numbers"
398-
assert tx["contexts"]["trace"]["data"][SPANDATA.MCP_METHOD_NAME] == "tools/call"
399-
assert tx["contexts"]["trace"]["data"][SPANDATA.MCP_TRANSPORT] == "http"
400-
assert tx["contexts"]["trace"]["data"][SPANDATA.MCP_REQUEST_ID] == "req-456"
401-
assert tx["contexts"]["trace"]["data"][SPANDATA.MCP_SESSION_ID] == session_id
396+
assert span["op"] == OP.MCP_SERVER
397+
assert span["origin"] == "auto.ai.mcp"
398+
assert span["description"] == "tools/call multiply_numbers"
399+
assert span["data"][SPANDATA.MCP_TOOL_NAME] == "multiply_numbers"
400+
assert span["data"][SPANDATA.MCP_METHOD_NAME] == "tools/call"
401+
assert span["data"][SPANDATA.MCP_TRANSPORT] == "http"
402+
assert span["data"][SPANDATA.MCP_REQUEST_ID] == "req-456"
403+
assert span["data"][SPANDATA.MCP_SESSION_ID] == session_id
402404

403405
# Check PII-sensitive data
404406
if send_default_pii and include_prompts:
405-
assert SPANDATA.MCP_TOOL_RESULT_CONTENT in tx["contexts"]["trace"]["data"]
407+
assert SPANDATA.MCP_TOOL_RESULT_CONTENT in span["data"]
406408
else:
407-
assert SPANDATA.MCP_TOOL_RESULT_CONTENT not in tx["contexts"]["trace"]["data"]
409+
assert SPANDATA.MCP_TOOL_RESULT_CONTENT not in span["data"]
408410

409411

410412
@pytest.mark.asyncio
@@ -672,7 +674,7 @@ async def test_fastmcp_prompt_async(
672674
capture_events,
673675
FastMCP,
674676
json_rpc,
675-
select_mcp_transactions,
677+
select_transactions_with_mcp_spans,
676678
):
677679
"""Test that FastMCP async prompt handlers create proper spans"""
678680
sentry_init(
@@ -732,7 +734,9 @@ async def async_prompt(topic: str):
732734

733735
assert len(result.json()["result"]["messages"]) == 2
734736

735-
transactions = select_mcp_transactions(events)
737+
transactions = select_transactions_with_mcp_spans(
738+
events, method_name="prompts/get"
739+
)
736740
assert len(transactions) == 1
737741

738742

@@ -805,7 +809,7 @@ async def test_fastmcp_resource_async(
805809
capture_events,
806810
FastMCP,
807811
json_rpc,
808-
select_mcp_transactions,
812+
select_transactions_with_mcp_spans,
809813
):
810814
"""Test that FastMCP async resource handlers create proper spans"""
811815
sentry_init(
@@ -855,13 +859,15 @@ async def read_url(resource: str):
855859

856860
assert "resource data" in result.json()["result"]["contents"][0]["text"]
857861

858-
transactions = select_mcp_transactions(events)
862+
transactions = select_transactions_with_mcp_spans(
863+
events, method_name="resources/read"
864+
)
859865
assert len(transactions) == 1
860866
tx = transactions[0]
861-
assert (
862-
tx["contexts"]["trace"]["data"][SPANDATA.MCP_RESOURCE_PROTOCOL]
863-
== "https"
864-
)
867+
assert len(tx["spans"]) == 1
868+
span = tx["spans"][0]
869+
870+
assert span["data"][SPANDATA.MCP_RESOURCE_PROTOCOL] == "https"
865871
except (AttributeError, TypeError):
866872
# Resource handler not supported in this version
867873
pytest.skip("Resource handlers not supported in this FastMCP version")
@@ -1003,7 +1009,7 @@ def test_fastmcp_http_transport(
10031009
capture_events,
10041010
FastMCP,
10051011
json_rpc,
1006-
select_mcp_transactions,
1012+
select_transactions_with_mcp_spans,
10071013
):
10081014
"""Test that FastMCP correctly detects HTTP transport"""
10091015
sentry_init(
@@ -1045,12 +1051,14 @@ def http_tool(data: str) -> dict:
10451051
"processed": "TEST"
10461052
}
10471053

1048-
transactions = select_mcp_transactions(events)
1054+
transactions = select_transactions_with_mcp_spans(events, method_name="tools/call")
10491055
assert len(transactions) == 1
10501056
tx = transactions[0]
1057+
assert len(tx["spans"]) == 1
1058+
span = tx["spans"][0]
10511059

10521060
# Check that HTTP transport is detected
1053-
assert tx["contexts"]["trace"]["data"].get(SPANDATA.MCP_TRANSPORT) == "http"
1061+
assert span["data"].get(SPANDATA.MCP_TRANSPORT) == "http"
10541062

10551063

10561064
@pytest.mark.asyncio

0 commit comments

Comments
 (0)