Skip to content
Draft
Show file tree
Hide file tree
Changes from 8 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
152 changes: 105 additions & 47 deletions sentry_sdk/integrations/mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 # type: ignore[import-not-found]
except ImportError:
raise DidNotEnable("MCP SDK not installed")

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


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

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


class MCPIntegration(Integration):
Expand All @@ -54,11 +57,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.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.
Expand Down Expand Up @@ -381,56 +407,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(
Expand Down Expand Up @@ -618,6 +655,27 @@ 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.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.
Expand Down
13 changes: 11 additions & 2 deletions sentry_sdk/scope.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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

Expand All @@ -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)

Expand Down Expand Up @@ -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

Expand All @@ -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()
Expand Down
1 change: 1 addition & 0 deletions tests/integrations/fastmcp/test_fastmcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading