Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
175 changes: 123 additions & 52 deletions sentry_sdk/integrations/mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,12 @@
from sentry_sdk.integrations import Integration, DidNotEnable
from sentry_sdk.utils import safe_serialize
from sentry_sdk.scope import should_send_default_pii
from sentry_sdk.integrations._wsgi_common import nullcontext

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 # type: ignore[import-not-found]
except ImportError:
raise DidNotEnable("MCP SDK not installed")

Expand All @@ -31,7 +33,9 @@


if TYPE_CHECKING:
from typing import Any, Callable, Optional
from typing import Any, Callable, Optional, Tuple, ContextManager

from starlette.types import Receive, Scope, Send # type: ignore[import-not-found]


class MCPIntegration(Integration):
Expand All @@ -54,11 +58,34 @@ 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() -> (
"Optional[Tuple[Optional[sentry_sdk.Scope], Optional[sentry_sdk.Scope]]]"
):
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.isolation_scope"),
ctx.request.scope["state"].get("sentry_sdk.current_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.
Expand Down Expand Up @@ -382,60 +409,85 @@ async def _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()

# Set input span data
_set_span_input_data(
span,
handler_name,
span_data_key,
mcp_method_name,
arguments,
request_id,
session_id,
mcp_transport,
scopes = _get_active_http_scopes()

isolation_scope_context: "ContextManager[Any]"
current_scope_context: "ContextManager[Any]"

if scopes is None:
isolation_scope_context = nullcontext()
current_scope_context = nullcontext()
else:
isolation_scope, current_scope = scopes

isolation_scope_context = (
nullcontext()
if isolation_scope is None
else sentry_sdk.scope.use_isolation_scope(isolation_scope)
)
current_scope_context = (
nullcontext()
if current_scope is None
else sentry_sdk.scope.use_scope(current_scope)
)

# 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)
# Get request ID, session ID, and transport from context
request_id, session_id, mcp_transport = _get_request_context_data()

try:
# Execute the async handler
if self is not None:
original_args = (self, *original_args)

result = func(*original_args, **original_kwargs)
if force_await or inspect.isawaitable(result):
result = await result

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)
return result
# Start span and execute
with isolation_scope_context:
with current_scope_context:
with get_start_span_function()(
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 = func(*original_args, **original_kwargs)
if force_await or inspect.isawaitable(result):
result = await result

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)

return result


def _create_instrumented_decorator(
Expand Down Expand Up @@ -521,6 +573,25 @@ def patched_read_resource(
Server.read_resource = patched_read_resource


def _patch_handle_request() -> None:
original_handle_request = StreamableHTTPServerTransport.handle_request

@wraps(original_handle_request)
async def patched_handle_request(
self: "StreamableHTTPServerTransport",
scope: "Scope",
receive: "Receive",
send: "Send",
) -> None:
scope.setdefault("state", {})["sentry_sdk.isolation_scope"] = (
sentry_sdk.get_isolation_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


def _patch_fastmcp() -> None:
"""
Patches the standalone fastmcp package's FastMCP class.
Expand Down
16 changes: 16 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -804,6 +804,22 @@ def inner(events):
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


@pytest.fixture()
def json_rpc_sse():
class StreamingASGITransport(ASGITransport):
Expand Down
54 changes: 31 additions & 23 deletions tests/integrations/fastmcp/test_fastmcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -343,7 +343,7 @@ async def test_fastmcp_tool_async(
send_default_pii,
include_prompts,
json_rpc,
select_mcp_transactions,
select_transactions_with_mcp_spans,
):
"""Test that FastMCP async tool handlers create proper spans"""
sentry_init(
Expand Down Expand Up @@ -387,24 +387,26 @@ async def multiply_numbers(x: int, y: int) -> dict:
"operation": "multiplication",
}

transactions = select_mcp_transactions(events)
transactions = select_transactions_with_mcp_spans(events, method_name="tools/call")
assert len(transactions) == 1
tx = transactions[0]
assert len(tx["spans"]) == 1
span = tx["spans"][0]

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
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

# Check PII-sensitive data
if send_default_pii and include_prompts:
assert SPANDATA.MCP_TOOL_RESULT_CONTENT in tx["contexts"]["trace"]["data"]
assert SPANDATA.MCP_TOOL_RESULT_CONTENT in span["data"]
else:
assert SPANDATA.MCP_TOOL_RESULT_CONTENT not in tx["contexts"]["trace"]["data"]
assert SPANDATA.MCP_TOOL_RESULT_CONTENT not in span["data"]


@pytest.mark.asyncio
Expand Down Expand Up @@ -672,7 +674,7 @@ async def test_fastmcp_prompt_async(
capture_events,
FastMCP,
json_rpc,
select_mcp_transactions,
select_transactions_with_mcp_spans,
):
"""Test that FastMCP async prompt handlers create proper spans"""
sentry_init(
Expand Down Expand Up @@ -732,7 +734,9 @@ async def async_prompt(topic: str):

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

transactions = select_mcp_transactions(events)
transactions = select_transactions_with_mcp_spans(
events, method_name="prompts/get"
)
assert len(transactions) == 1


Expand Down Expand Up @@ -805,7 +809,7 @@ async def test_fastmcp_resource_async(
capture_events,
FastMCP,
json_rpc,
select_mcp_transactions,
select_transactions_with_mcp_spans,
):
"""Test that FastMCP async resource handlers create proper spans"""
sentry_init(
Expand Down Expand Up @@ -855,13 +859,15 @@ async def read_url(resource: str):

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

transactions = select_mcp_transactions(events)
transactions = select_transactions_with_mcp_spans(
events, method_name="resources/read"
)
assert len(transactions) == 1
tx = transactions[0]
assert (
tx["contexts"]["trace"]["data"][SPANDATA.MCP_RESOURCE_PROTOCOL]
== "https"
)
assert len(tx["spans"]) == 1
span = tx["spans"][0]

assert span["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")
Expand Down Expand Up @@ -1003,7 +1009,7 @@ def test_fastmcp_http_transport(
capture_events,
FastMCP,
json_rpc,
select_mcp_transactions,
select_transactions_with_mcp_spans,
):
"""Test that FastMCP correctly detects HTTP transport"""
sentry_init(
Expand Down Expand Up @@ -1045,12 +1051,14 @@ def http_tool(data: str) -> dict:
"processed": "TEST"
}

transactions = select_mcp_transactions(events)
transactions = select_transactions_with_mcp_spans(events, method_name="tools/call")
assert len(transactions) == 1
tx = transactions[0]
assert len(tx["spans"]) == 1
span = tx["spans"][0]

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


@pytest.mark.asyncio
Expand Down
Loading
Loading