From 0e93d4ce8bf182c2994df2af7d8c80a1dffded35 Mon Sep 17 00:00:00 2001 From: Fabian Schindler Date: Tue, 14 Oct 2025 16:46:34 +0200 Subject: [PATCH 01/18] feat(integrations): initial implementation of MCP integration --- sentry_sdk/consts.py | 33 ++ sentry_sdk/integrations/__init__.py | 1 + sentry_sdk/integrations/mcp/__init__.py | 37 ++ sentry_sdk/integrations/mcp/fastmcp.py | 438 ++++++++++++++++++++++++ sentry_sdk/integrations/mcp/lowlevel.py | 203 +++++++++++ 5 files changed, 712 insertions(+) create mode 100644 sentry_sdk/integrations/mcp/__init__.py create mode 100644 sentry_sdk/integrations/mcp/fastmcp.py create mode 100644 sentry_sdk/integrations/mcp/lowlevel.py diff --git a/sentry_sdk/consts.py b/sentry_sdk/consts.py index c1e587cbeb..f64a456bd4 100644 --- a/sentry_sdk/consts.py +++ b/sentry_sdk/consts.py @@ -749,6 +749,36 @@ class SPANDATA: Example: "MainThread" """ + MCP_TOOL_NAME = "mcp.tool.name" + """ + The name of the MCP tool being called. + Example: "get_weather" + """ + + MCP_PROMPT_NAME = "mcp.prompt.name" + """ + The name of the MCP prompt being retrieved. + Example: "code_review" + """ + + MCP_RESOURCE_URI = "mcp.resource.uri" + """ + The URI of the MCP resource being accessed. + Example: "file:///path/to/resource" + """ + + MCP_METHOD_NAME = "mcp.method.name" + """ + The MCP protocol method name being called. + Example: "tools/call", "prompts/get", "resources/read" + """ + + MCP_REQUEST_ID = "mcp.request.id" + """ + The unique identifier for the MCP request. + Example: "req_123abc" + """ + class SPANSTATUS: """ @@ -845,6 +875,9 @@ class OP: WEBSOCKET_SERVER = "websocket.server" SOCKET_CONNECTION = "socket.connection" SOCKET_DNS = "socket.dns" + MCP_TOOL = "mcp.tool" + MCP_PROMPT = "mcp.prompt" + MCP_RESOURCE = "mcp.resource" # This type exists to trick mypy and PyCharm into thinking `init` and `Client` diff --git a/sentry_sdk/integrations/__init__.py b/sentry_sdk/integrations/__init__.py index 9e279b8345..8db2aa5d30 100644 --- a/sentry_sdk/integrations/__init__.py +++ b/sentry_sdk/integrations/__init__.py @@ -98,6 +98,7 @@ def iter_default_integrations(with_auto_enabling_integrations): "sentry_sdk.integrations.langgraph.LanggraphIntegration", "sentry_sdk.integrations.litestar.LitestarIntegration", "sentry_sdk.integrations.loguru.LoguruIntegration", + "sentry_sdk.integrations.mcp.MCPIntegration", "sentry_sdk.integrations.openai.OpenAIIntegration", "sentry_sdk.integrations.pymongo.PyMongoIntegration", "sentry_sdk.integrations.pyramid.PyramidIntegration", diff --git a/sentry_sdk/integrations/mcp/__init__.py b/sentry_sdk/integrations/mcp/__init__.py new file mode 100644 index 0000000000..9bf1f7cbd6 --- /dev/null +++ b/sentry_sdk/integrations/mcp/__init__.py @@ -0,0 +1,37 @@ +""" +Sentry integration for MCP (Model Context Protocol) servers. + +This integration instruments MCP servers to create spans for tool, prompt, +and resource handler execution, and captures errors that occur during execution. + +Supports both the low-level `mcp.server.lowlevel.Server` and high-level +`mcp.server.fastmcp.FastMCP` APIs. +""" + +from sentry_sdk.integrations import Integration, DidNotEnable + +try: + import mcp.server.lowlevel # noqa: F401 + import mcp.server.fastmcp # noqa: F401 +except ImportError: + raise DidNotEnable("MCP SDK not installed") + + +class MCPIntegration(Integration): + identifier = "mcp" + origin = "auto.ai.mcp" + + @staticmethod + def setup_once(): + # type: () -> None + """ + Patches MCP server classes to instrument handler execution. + """ + from sentry_sdk.integrations.mcp.lowlevel import patch_lowlevel_server + from sentry_sdk.integrations.mcp.fastmcp import patch_fastmcp_server + + patch_lowlevel_server() + patch_fastmcp_server() + + +__all__ = ["MCPIntegration"] diff --git a/sentry_sdk/integrations/mcp/fastmcp.py b/sentry_sdk/integrations/mcp/fastmcp.py new file mode 100644 index 0000000000..a45916cfec --- /dev/null +++ b/sentry_sdk/integrations/mcp/fastmcp.py @@ -0,0 +1,438 @@ +""" +Patches for mcp.server.fastmcp.FastMCP class. +""" + +import inspect +from functools import wraps +from inspect import Parameter, Signature +from typing import TYPE_CHECKING + +import sentry_sdk +from sentry_sdk.ai.utils import get_start_span_function +from sentry_sdk.consts import OP, SPANDATA +from sentry_sdk.integrations.mcp import MCPIntegration +from sentry_sdk.utils import safe_serialize + +if TYPE_CHECKING: + from typing import Any, Callable + +from mcp.server.fastmcp import Context +from mcp.server.fastmcp.utilities.context_injection import find_context_parameter + + +def _get_span_config(handler_type, handler_name): + # type: (str, str) -> tuple[str, str, str] + """ + Get span configuration based on handler type. + + Returns: + Tuple of (op, span_data_key, span_name) + """ + if handler_type == "tool": + op = OP.MCP_TOOL + span_data_key = SPANDATA.MCP_TOOL_NAME + mcp_method_name = "tools/call" + elif handler_type == "prompt": + op = OP.MCP_PROMPT + span_data_key = SPANDATA.MCP_PROMPT_NAME + mcp_method_name = "prompts/get" + else: # resource + op = OP.MCP_RESOURCE + span_data_key = SPANDATA.MCP_RESOURCE_URI + mcp_method_name = "resources/read" + + span_name = f"{handler_type} {handler_name}" + return op, span_data_key, span_name, mcp_method_name + + +def _set_span_data(span, handler_name, span_data_key, mcp_method_name, kwargs): + # type: (Any, str, str, str, dict[str, Any]) -> None + """Set common span data for MCP handlers (legacy, kept for compatibility).""" + # Extract context_obj from kwargs + context_obj = None + for v in kwargs.values(): + if isinstance(v, Context): + context_obj = v + break + + _set_span_data_with_context( + span, handler_name, span_data_key, mcp_method_name, kwargs, context_obj + ) + + +def _set_span_data_with_context( + span, handler_name, span_data_key, mcp_method_name, kwargs, context_obj +): + # type: (Any, str, str, str, dict[str, Any], Any) -> None + """Set common span data for MCP handlers with explicit context object.""" + # Set handler identifier + span.set_data(span_data_key, handler_name) + span.set_data(SPANDATA.MCP_METHOD_NAME, mcp_method_name) + + # Extract request_id from Context if provided + if context_obj is not None: + try: + request_id = context_obj.request_id + if request_id: + span.set_data(SPANDATA.MCP_REQUEST_ID, request_id) + except Exception: + # Silently ignore if we can't get request_id + pass + + # Set request arguments (excluding Context objects and internal keys) + for k, v in kwargs.items(): + if isinstance(v, Context): + continue + span.set_data(f"mcp.request.argument.{k}", safe_serialize(v)) + + +def _extract_context_from_kwargs(kwargs): + # type: (dict[str, Any]) -> Any + """Extract Context object from kwargs, returns None if not found.""" + for v in kwargs.values(): + if isinstance(v, Context): + return v + return None + + +def _create_span_wrapper( + original_handler, + has_context, + should_inject_context, + op, + span_name, + handler_name, + span_data_key, + mcp_method_name, +): + # type: (Callable[..., Any], bool, bool, str, str, str, str, str) -> Callable[..., Any] + """ + Creates a wrapper function that handles span creation for MCP handlers. + + This factory function eliminates code duplication by handling all + combinations of sync/async, with/without context, and with/without injection. + + Strategy Matrix: + ================ + + Case 1: has_context=True + - Original handler already has a Context parameter + - Returns: wrapper that extracts Context from kwargs and passes through + - Context flow: FastMCP → wrapper → original handler + - Request ID captured from Context + + Case 2: has_context=False, should_inject_context=True + - Original handler does NOT have a Context parameter + - Returns: wrapper with injected sentry_ctx parameter + - Context flow: FastMCP → wrapper (captures ctx) → execute_with_span + - Original handler called WITHOUT the Context (transparent injection) + - Request ID captured from injected Context + - Applies to: Tools without Context + + Case 3: has_context=False, should_inject_context=False + - Original handler does NOT have a Context parameter + - No Context injection (to avoid template registration for resources) + - Returns: simple wrapper with no Context access + - No request ID capture + - Applies to: Resources and prompts without Context + + Key Design Points: + ================== + - All cases: Use @wraps decorator to preserve function metadata + - Case 2: Add sentry_ctx: Context to __annotations__ for FastMCP injection + - Case 2: Modify __signature__ to include sentry_ctx parameter + - All cases: Use execute_with_span[_async]() for actual span creation (DRY) + - All cases: Context extraction and span data setting handled uniformly + + Args: + original_handler: The user's handler function to wrap + has_context: Whether original_handler already has a Context parameter + should_inject_context: Whether to inject sentry_ctx parameter + op: Span operation type (e.g., "mcp.tool") + span_name: Human-readable span name (e.g., "tool my_tool") + handler_name: Handler identifier (tool name, prompt name, or resource URI) + span_data_key: Span data key for handler identifier + mcp_method_name: MCP protocol method (e.g., "tools/call") + + Returns: + Wrapped function with span instrumentation and Context handling + """ + + def execute_with_span(context_obj, args, kwargs): + # type: (Any, tuple[Any, ...], dict[str, Any]) -> Any + """Execute the handler within a span context.""" + with get_start_span_function()( + op=op, + name=span_name, + origin=MCPIntegration.origin, + ) as span: + _set_span_data_with_context( + span, + handler_name, + span_data_key, + mcp_method_name, + kwargs, + context_obj, + ) + + try: + return original_handler(*args, **kwargs) + except Exception as e: + sentry_sdk.capture_exception(e) + raise + + async def execute_with_span_async(context_obj, args, kwargs): + # type: (Any, tuple[Any, ...], dict[str, Any]) -> Any + """Execute the async handler within a span context.""" + with get_start_span_function()( + op=op, + name=span_name, + origin=MCPIntegration.origin, + ) as span: + _set_span_data_with_context( + span, + handler_name, + span_data_key, + mcp_method_name, + kwargs, + context_obj, + ) + + try: + return await original_handler(*args, **kwargs) + except Exception as e: + sentry_sdk.capture_exception(e) + raise + + is_async = inspect.iscoroutinefunction(original_handler) + + if has_context: + # Original has Context - just pass through + if is_async: + + @wraps(original_handler) + async def wrapper(*args, **kwargs): + # type: (*Any, **Any) -> Any + context_obj = _extract_context_from_kwargs(kwargs) + return await execute_with_span_async(context_obj, args, kwargs) + + return wrapper + else: + + @wraps(original_handler) + def wrapper(*args, **kwargs): + # type: (*Any, **Any) -> Any + context_obj = _extract_context_from_kwargs(kwargs) + return execute_with_span(context_obj, args, kwargs) + + return wrapper + elif should_inject_context: + # Original doesn't have Context - need to inject sentry_ctx parameter + # We need to preserve the original signature and add sentry_ctx + + # Get original signature + try: + orig_sig = inspect.signature(original_handler) + except (ValueError, TypeError): + # If we can't get signature, fall back to simple wrapper + orig_sig = None + + if is_async: + if orig_sig is not None: + # Create new signature with sentry_ctx parameter added + new_params = list(orig_sig.parameters.values()) + # Add sentry_ctx as keyword-only parameter with default None + sentry_ctx_param = Parameter( + "sentry_ctx", + Parameter.KEYWORD_ONLY, + default=None, + annotation=Context, + ) + new_params.append(sentry_ctx_param) + new_sig = orig_sig.replace(parameters=new_params) + + # Create wrapper with proper signature + @wraps(original_handler) + async def outer_wrapper(*args, **kwargs): + # type: (*Any, **Any) -> Any + sentry_ctx = kwargs.pop("sentry_ctx", None) + return await execute_with_span_async(sentry_ctx, args, kwargs) + + # Set the proper signature + outer_wrapper.__signature__ = new_sig # type: ignore[attr-defined] + else: + # Fallback: use simple wrapper + @wraps(original_handler) + async def outer_wrapper(*args, **kwargs): + # type: (*Any, **Any) -> Any + sentry_ctx = kwargs.pop("sentry_ctx", None) + return await execute_with_span_async(sentry_ctx, args, kwargs) + + # Add Context annotation + outer_wrapper.__annotations__ = getattr( + original_handler, "__annotations__", {} + ).copy() + outer_wrapper.__annotations__["sentry_ctx"] = Context + + return outer_wrapper + else: + if orig_sig is not None: + # Create new signature with sentry_ctx parameter added + new_params = list(orig_sig.parameters.values()) + # Add sentry_ctx as keyword-only parameter with default None + sentry_ctx_param = Parameter( + "sentry_ctx", + Parameter.KEYWORD_ONLY, + default=None, + annotation=Context, + ) + new_params.append(sentry_ctx_param) + new_sig = orig_sig.replace(parameters=new_params) + + # Create wrapper with proper signature + @wraps(original_handler) + def outer_wrapper(*args, **kwargs): + # type: (*Any, **Any) -> Any + sentry_ctx = kwargs.pop("sentry_ctx", None) + return execute_with_span(sentry_ctx, args, kwargs) + + # Set the proper signature + outer_wrapper.__signature__ = new_sig # type: ignore[attr-defined] + else: + # Fallback: use simple wrapper + @wraps(original_handler) + def outer_wrapper(*args, **kwargs): + # type: (*Any, **Any) -> Any + sentry_ctx = kwargs.pop("sentry_ctx", None) + return execute_with_span(sentry_ctx, args, kwargs) + + # Add Context annotation + outer_wrapper.__annotations__ = getattr( + original_handler, "__annotations__", {} + ).copy() + outer_wrapper.__annotations__["sentry_ctx"] = Context + + return outer_wrapper + else: + # No context and no injection - simple pass-through wrapper + if is_async: + + @wraps(original_handler) + async def wrapper(*args, **kwargs): + # type: (*Any, **Any) -> Any + return await execute_with_span_async(None, args, kwargs) + + return wrapper + else: + + @wraps(original_handler) + def wrapper(*args, **kwargs): + # type: (*Any, **Any) -> Any + return execute_with_span(None, args, kwargs) + + return wrapper + + +def wrap_handler(original_handler, handler_type, handler_name): + # type: (Callable[..., Any], str, str) -> Callable[..., Any] + """ + Wraps a handler function to create spans and capture errors. + + Context injection strategy: + - Tools without Context: Inject sentry_ctx to capture request_id + - Resources/prompts without Context: No injection (avoids template registration) + - Handlers with Context: Use existing Context to capture request_id + + This ensures resources are registered correctly (not as templates) while + still capturing request_id for tools. + + Args: + original_handler: The original handler function to wrap + handler_type: Type of handler ('tool', 'prompt', or 'resource') + handler_name: Name or identifier of the handler + + Returns: + Wrapped handler function + """ + op, span_data_key, span_name, mcp_method_name = _get_span_config( + handler_type, handler_name + ) + + # Check if the original handler already has a Context parameter + has_context = find_context_parameter(original_handler) is not None + + # For now, only inject context for tools to avoid resources being registered as templates + # TODO: Find a better way to capture request_id for resources/prompts without Context + should_inject_context = handler_type == "tool" and not has_context + + return _create_span_wrapper( + original_handler, + has_context, + should_inject_context, + op, + span_name, + handler_name, + span_data_key, + mcp_method_name, + ) + + +def patch_fastmcp_server(): + # type: () -> None + """ + Patches the mcp.server.fastmcp.FastMCP class to instrument handler execution. + """ + from mcp.server.fastmcp import FastMCP + + # Patch tool decorator + original_tool = FastMCP.tool + + def patched_tool(self, name=None, **kwargs): + # type: (FastMCP, str | None, **Any) -> Callable[[Callable[..., Any]], Callable[..., Any]] + def decorator(func): + # type: (Callable[..., Any]) -> Callable[..., Any] + # Use provided name or function name + tool_name = name if name else getattr(func, "__name__", "unknown") + wrapped_func = wrap_handler(func, "tool", tool_name) + + # Call the original decorator with the wrapped function + return original_tool(self, name=name, **kwargs)(wrapped_func) + + return decorator + + FastMCP.tool = patched_tool # type: ignore + + # Patch prompt decorator + original_prompt = FastMCP.prompt + + def patched_prompt(self, name=None, **kwargs): + # type: (FastMCP, str | None, **Any) -> Callable[[Callable[..., Any]], Callable[..., Any]] + def decorator(func): + # type: (Callable[..., Any]) -> Callable[..., Any] + # Use provided name or function name + prompt_name = name if name else getattr(func, "__name__", "unknown") + wrapped_func = wrap_handler(func, "prompt", prompt_name) + + # Call the original decorator with the wrapped function + return original_prompt(self, name=name, **kwargs)(wrapped_func) + + return decorator + + FastMCP.prompt = patched_prompt # type: ignore + + # Patch resource decorator + original_resource = FastMCP.resource + + def patched_resource(self, uri, **kwargs): + # type: (FastMCP, str, **Any) -> Callable[[Callable[..., Any]], Callable[..., Any]] + def decorator(func): + # type: (Callable[..., Any]) -> Callable[..., Any] + # Use the URI as the identifier + wrapped_func = wrap_handler(func, "resource", uri) + + # Call the original decorator with the wrapped function + return original_resource(self, uri, **kwargs)(wrapped_func) + + return decorator + + FastMCP.resource = patched_resource # type: ignore diff --git a/sentry_sdk/integrations/mcp/lowlevel.py b/sentry_sdk/integrations/mcp/lowlevel.py new file mode 100644 index 0000000000..83b6de1a24 --- /dev/null +++ b/sentry_sdk/integrations/mcp/lowlevel.py @@ -0,0 +1,203 @@ +""" +Patches for mcp.server.lowlevel.Server class. +""" + +import inspect +from functools import wraps +from typing import TYPE_CHECKING + +import sentry_sdk +from sentry_sdk.ai.utils import get_start_span_function +from sentry_sdk.consts import OP, SPANDATA +from sentry_sdk.integrations.mcp import MCPIntegration +from sentry_sdk.utils import safe_serialize + +if TYPE_CHECKING: + from typing import Any, Callable + + +def _get_span_config(handler_type, handler_name): + # type: (str, str) -> tuple[str, str, str, str] + """ + Get span configuration based on handler type. + + Returns: + Tuple of (op, span_data_key, span_name, mcp_method_name) + """ + if handler_type == "tool": + op = OP.MCP_TOOL + span_data_key = SPANDATA.MCP_TOOL_NAME + mcp_method_name = "tools/call" + elif handler_type == "prompt": + op = OP.MCP_PROMPT + span_data_key = SPANDATA.MCP_PROMPT_NAME + mcp_method_name = "prompts/get" + else: # resource + op = OP.MCP_RESOURCE + span_data_key = SPANDATA.MCP_RESOURCE_URI + mcp_method_name = "resources/read" + + span_name = f"{handler_type} {handler_name}" + return op, span_data_key, span_name, mcp_method_name + + +def _set_span_data(span, handler_name, span_data_key, mcp_method_name, kwargs): + # type: (Any, str, str, str, dict[str, Any]) -> None + """Set common span data for MCP handlers.""" + # Set handler identifier + span.set_data(span_data_key, handler_name) + span.set_data(SPANDATA.MCP_METHOD_NAME, mcp_method_name) + + # Set request arguments (excluding common request context objects) + for k, v in kwargs.items(): + span.set_data(f"mcp.request.argument.{k}", safe_serialize(v)) + + +def wrap_handler(original_handler, handler_type, handler_name): + # type: (Callable[..., Any], str, str) -> Callable[..., Any] + """ + Wraps a handler function to create spans and capture errors. + + Args: + original_handler: The original handler function to wrap + handler_type: Type of handler ('tool', 'prompt', or 'resource') + handler_name: Name or identifier of the handler + + Returns: + Wrapped handler function + """ + op, span_data_key, span_name, mcp_method_name = _get_span_config( + handler_type, handler_name + ) + + if inspect.iscoroutinefunction(original_handler): + + @wraps(original_handler) + async def async_wrapper(*args, **kwargs): + # type: (*Any, **Any) -> Any + with get_start_span_function()( + op=op, + name=span_name, + origin=MCPIntegration.origin, + ) as span: + _set_span_data( + span, handler_name, span_data_key, mcp_method_name, kwargs + ) + try: + return await original_handler(*args, **kwargs) + except Exception as e: + sentry_sdk.capture_exception(e) + raise + + return async_wrapper + else: + + @wraps(original_handler) + def sync_wrapper(*args, **kwargs): + # type: (*Any, **Any) -> Any + with get_start_span_function()( + op=op, + name=span_name, + origin=MCPIntegration.origin, + ) as span: + _set_span_data( + span, handler_name, span_data_key, mcp_method_name, kwargs + ) + try: + return original_handler(*args, **kwargs) + except Exception as e: + sentry_sdk.capture_exception(e) + raise + + return sync_wrapper + + +def patch_lowlevel_server(): + # type: () -> None + """ + Patches the mcp.server.lowlevel.Server class to instrument handler execution. + """ + from mcp.server.lowlevel import Server + + # Patch call_tool decorator + original_call_tool = Server.call_tool + + def patched_call_tool(self, **kwargs): + # type: (Server, **Any) -> Callable[[Callable[..., Any]], Callable[..., Any]] + def decorator(func): + # type: (Callable[..., Any]) -> Callable[..., Any] + # Extract tool name from the function + tool_name = getattr(func, "__name__", "unknown") + wrapped_func = wrap_handler(func, "tool", tool_name) + + # Call the original decorator with the wrapped function + return original_call_tool(self, **kwargs)(wrapped_func) + + return decorator + + Server.call_tool = patched_call_tool # type: ignore + + # Patch get_prompt decorator + original_get_prompt = Server.get_prompt + + def patched_get_prompt(self): + # type: (Server) -> Callable[[Callable[..., Any]], Callable[..., Any]] + def decorator(func): + # type: (Callable[..., Any]) -> Callable[..., Any] + # The handler receives (name, arguments) as parameters + # We need to extract the name from the call + if inspect.iscoroutinefunction(func): + + @wraps(func) + async def async_name_wrapper(name, arguments): + # type: (str, Any) -> Any + wrapped = wrap_handler(func, "prompt", name) + return await wrapped(name, arguments) + + return original_get_prompt(self)(async_name_wrapper) + else: + + @wraps(func) + def sync_name_wrapper(name, arguments): + # type: (str, Any) -> Any + wrapped = wrap_handler(func, "prompt", name) + return wrapped(name, arguments) + + return original_get_prompt(self)(sync_name_wrapper) + + return decorator + + Server.get_prompt = patched_get_prompt # type: ignore + + # Patch read_resource decorator + original_read_resource = Server.read_resource + + def patched_read_resource(self): + # type: (Server) -> Callable[[Callable[..., Any]], Callable[..., Any]] + def decorator(func): + # type: (Callable[..., Any]) -> Callable[..., Any] + # The handler receives a URI as a parameter + if inspect.iscoroutinefunction(func): + + @wraps(func) + async def async_uri_wrapper(uri): + # type: (Any) -> Any + uri_str = str(uri) if uri else "unknown" + wrapped = wrap_handler(func, "resource", uri_str) + return await wrapped(uri) + + return original_read_resource(self)(async_uri_wrapper) + else: + + @wraps(func) + def sync_uri_wrapper(uri): + # type: (Any) -> Any + uri_str = str(uri) if uri else "unknown" + wrapped = wrap_handler(func, "resource", uri_str) + return wrapped(uri) + + return original_read_resource(self)(sync_uri_wrapper) + + return decorator + + Server.read_resource = patched_read_resource # type: ignore From efad41b19d644a9b8d50cb17b6921d59f0a7484d Mon Sep 17 00:00:00 2001 From: Fabian Schindler Date: Thu, 16 Oct 2025 11:02:14 +0200 Subject: [PATCH 02/18] fix: improved low-level mcp instrumentation --- sentry_sdk/consts.py | 4 +- sentry_sdk/integrations/mcp/lowlevel.py | 60 +++++++++++++++++++------ 2 files changed, 47 insertions(+), 17 deletions(-) diff --git a/sentry_sdk/consts.py b/sentry_sdk/consts.py index f64a456bd4..1d383446e7 100644 --- a/sentry_sdk/consts.py +++ b/sentry_sdk/consts.py @@ -875,9 +875,7 @@ class OP: WEBSOCKET_SERVER = "websocket.server" SOCKET_CONNECTION = "socket.connection" SOCKET_DNS = "socket.dns" - MCP_TOOL = "mcp.tool" - MCP_PROMPT = "mcp.prompt" - MCP_RESOURCE = "mcp.resource" + MCP_SERVER = "mcp.server" # This type exists to trick mypy and PyCharm into thinking `init` and `Client` diff --git a/sentry_sdk/integrations/mcp/lowlevel.py b/sentry_sdk/integrations/mcp/lowlevel.py index 83b6de1a24..e129d48934 100644 --- a/sentry_sdk/integrations/mcp/lowlevel.py +++ b/sentry_sdk/integrations/mcp/lowlevel.py @@ -12,42 +12,49 @@ from sentry_sdk.integrations.mcp import MCPIntegration from sentry_sdk.utils import safe_serialize +from mcp.server.lowlevel import Server +from mcp.server.lowlevel.server import request_ctx + + if TYPE_CHECKING: from typing import Any, Callable def _get_span_config(handler_type, handler_name): - # type: (str, str) -> tuple[str, str, str, str] + # type: (str, str) -> tuple[str, str, str] """ Get span configuration based on handler type. Returns: - Tuple of (op, span_data_key, span_name, mcp_method_name) + Tuple of (span_data_key, span_name, mcp_method_name) """ if handler_type == "tool": - op = OP.MCP_TOOL span_data_key = SPANDATA.MCP_TOOL_NAME mcp_method_name = "tools/call" elif handler_type == "prompt": - op = OP.MCP_PROMPT span_data_key = SPANDATA.MCP_PROMPT_NAME mcp_method_name = "prompts/get" else: # resource - op = OP.MCP_RESOURCE span_data_key = SPANDATA.MCP_RESOURCE_URI mcp_method_name = "resources/read" span_name = f"{handler_type} {handler_name}" - return op, span_data_key, span_name, mcp_method_name + return span_data_key, span_name, mcp_method_name -def _set_span_data(span, handler_name, span_data_key, mcp_method_name, kwargs): - # type: (Any, str, str, str, dict[str, Any]) -> None +def _set_span_data( + span, handler_name, span_data_key, mcp_method_name, kwargs, request_id=None +): + # type: (Any, str, str, str, dict[str, Any], str | None) -> None """Set common span data for MCP handlers.""" # Set handler identifier span.set_data(span_data_key, handler_name) span.set_data(SPANDATA.MCP_METHOD_NAME, mcp_method_name) + # Set request_id if provided + if request_id: + span.set_data(SPANDATA.MCP_REQUEST_ID, request_id) + # Set request arguments (excluding common request context objects) for k, v in kwargs.items(): span.set_data(f"mcp.request.argument.{k}", safe_serialize(v)) @@ -66,7 +73,7 @@ def wrap_handler(original_handler, handler_type, handler_name): Returns: Wrapped handler function """ - op, span_data_key, span_name, mcp_method_name = _get_span_config( + span_data_key, span_name, mcp_method_name = _get_span_config( handler_type, handler_name ) @@ -76,12 +83,25 @@ def wrap_handler(original_handler, handler_type, handler_name): async def async_wrapper(*args, **kwargs): # type: (*Any, **Any) -> Any with get_start_span_function()( - op=op, + op=OP.MCP_SERVER, name=span_name, origin=MCPIntegration.origin, ) as span: + # Extract request_id from RequestContext context variable + request_id = None + try: + ctx = request_ctx.get() + request_id = ctx.request_id + except LookupError: + # Not in a request context, request_id will remain None + pass _set_span_data( - span, handler_name, span_data_key, mcp_method_name, kwargs + span, + handler_name, + span_data_key, + mcp_method_name, + kwargs, + request_id, ) try: return await original_handler(*args, **kwargs) @@ -96,12 +116,25 @@ async def async_wrapper(*args, **kwargs): def sync_wrapper(*args, **kwargs): # type: (*Any, **Any) -> Any with get_start_span_function()( - op=op, + op=OP.MCP_SERVER, name=span_name, origin=MCPIntegration.origin, ) as span: + # Extract request_id from RequestContext context variable + request_id = None + try: + ctx = request_ctx.get() + request_id = ctx.request_id + except LookupError: + # Not in a request context, request_id will remain None + pass _set_span_data( - span, handler_name, span_data_key, mcp_method_name, kwargs + span, + handler_name, + span_data_key, + mcp_method_name, + kwargs, + request_id, ) try: return original_handler(*args, **kwargs) @@ -117,7 +150,6 @@ def patch_lowlevel_server(): """ Patches the mcp.server.lowlevel.Server class to instrument handler execution. """ - from mcp.server.lowlevel import Server # Patch call_tool decorator original_call_tool = Server.call_tool From 6a2b4190464ce8059b0e98f16f898ecc188bccc5 Mon Sep 17 00:00:00 2001 From: Fabian Schindler Date: Thu, 16 Oct 2025 15:26:39 +0200 Subject: [PATCH 03/18] feat: improving input/output span attributes Transport mechanism detection not yet working --- sentry_sdk/consts.py | 48 +++ sentry_sdk/integrations/mcp/__init__.py | 4 +- sentry_sdk/integrations/mcp/fastmcp.py | 438 --------------------- sentry_sdk/integrations/mcp/lowlevel.py | 461 +++++++++++++++++------ sentry_sdk/integrations/mcp/transport.py | 48 +++ 5 files changed, 444 insertions(+), 555 deletions(-) delete mode 100644 sentry_sdk/integrations/mcp/fastmcp.py create mode 100644 sentry_sdk/integrations/mcp/transport.py diff --git a/sentry_sdk/consts.py b/sentry_sdk/consts.py index 1d383446e7..3f44c476d7 100644 --- a/sentry_sdk/consts.py +++ b/sentry_sdk/consts.py @@ -779,6 +779,54 @@ class SPANDATA: Example: "req_123abc" """ + MCP_TOOL_RESULT_CONTENT = "mcp.tool.result.content" + """ + The result/output content from an MCP tool execution. + Example: "The weather is sunny" + """ + + MCP_TOOL_RESULT_CONTENT_COUNT = "mcp.tool.result.content_count" + """ + The number of items/keys in the MCP tool result. + Example: 5 + """ + + MCP_TOOL_RESULT_IS_ERROR = "mcp.tool.result.is_error" + """ + Whether the MCP tool execution resulted in an error. + Example: True + """ + + MCP_PROMPT_RESULT_MESSAGE_CONTENT = "mcp.prompt.result.message_content" + """ + The message content from an MCP prompt retrieval. + Example: "Review the following code..." + """ + + MCP_PROMPT_RESULT_MESSAGE_ROLE = "mcp.prompt.result.message_role" + """ + The role of the message in an MCP prompt retrieval (only set for single-message prompts). + Example: "user", "assistant", "system" + """ + + MCP_PROMPT_RESULT_MESSAGE_COUNT = "mcp.prompt.result.message_count" + """ + The number of messages in an MCP prompt result. + Example: 1, 3 + """ + + MCP_RESOURCE_RESULT_CONTENT = "mcp.resource.result.content" + """ + The result/output content from an MCP resource read. + Example: "File contents..." + """ + + MCP_TRANSPORT = "mcp.transport" + """ + The transport method used for MCP communication. + Example: "pipe" (stdio), "tcp" (HTTP/WebSocket/SSE) + """ + class SPANSTATUS: """ diff --git a/sentry_sdk/integrations/mcp/__init__.py b/sentry_sdk/integrations/mcp/__init__.py index 9bf1f7cbd6..0a4e9fa704 100644 --- a/sentry_sdk/integrations/mcp/__init__.py +++ b/sentry_sdk/integrations/mcp/__init__.py @@ -12,7 +12,6 @@ try: import mcp.server.lowlevel # noqa: F401 - import mcp.server.fastmcp # noqa: F401 except ImportError: raise DidNotEnable("MCP SDK not installed") @@ -28,10 +27,9 @@ def setup_once(): Patches MCP server classes to instrument handler execution. """ from sentry_sdk.integrations.mcp.lowlevel import patch_lowlevel_server - from sentry_sdk.integrations.mcp.fastmcp import patch_fastmcp_server + # Patch server classes to instrument handlers patch_lowlevel_server() - patch_fastmcp_server() __all__ = ["MCPIntegration"] diff --git a/sentry_sdk/integrations/mcp/fastmcp.py b/sentry_sdk/integrations/mcp/fastmcp.py deleted file mode 100644 index a45916cfec..0000000000 --- a/sentry_sdk/integrations/mcp/fastmcp.py +++ /dev/null @@ -1,438 +0,0 @@ -""" -Patches for mcp.server.fastmcp.FastMCP class. -""" - -import inspect -from functools import wraps -from inspect import Parameter, Signature -from typing import TYPE_CHECKING - -import sentry_sdk -from sentry_sdk.ai.utils import get_start_span_function -from sentry_sdk.consts import OP, SPANDATA -from sentry_sdk.integrations.mcp import MCPIntegration -from sentry_sdk.utils import safe_serialize - -if TYPE_CHECKING: - from typing import Any, Callable - -from mcp.server.fastmcp import Context -from mcp.server.fastmcp.utilities.context_injection import find_context_parameter - - -def _get_span_config(handler_type, handler_name): - # type: (str, str) -> tuple[str, str, str] - """ - Get span configuration based on handler type. - - Returns: - Tuple of (op, span_data_key, span_name) - """ - if handler_type == "tool": - op = OP.MCP_TOOL - span_data_key = SPANDATA.MCP_TOOL_NAME - mcp_method_name = "tools/call" - elif handler_type == "prompt": - op = OP.MCP_PROMPT - span_data_key = SPANDATA.MCP_PROMPT_NAME - mcp_method_name = "prompts/get" - else: # resource - op = OP.MCP_RESOURCE - span_data_key = SPANDATA.MCP_RESOURCE_URI - mcp_method_name = "resources/read" - - span_name = f"{handler_type} {handler_name}" - return op, span_data_key, span_name, mcp_method_name - - -def _set_span_data(span, handler_name, span_data_key, mcp_method_name, kwargs): - # type: (Any, str, str, str, dict[str, Any]) -> None - """Set common span data for MCP handlers (legacy, kept for compatibility).""" - # Extract context_obj from kwargs - context_obj = None - for v in kwargs.values(): - if isinstance(v, Context): - context_obj = v - break - - _set_span_data_with_context( - span, handler_name, span_data_key, mcp_method_name, kwargs, context_obj - ) - - -def _set_span_data_with_context( - span, handler_name, span_data_key, mcp_method_name, kwargs, context_obj -): - # type: (Any, str, str, str, dict[str, Any], Any) -> None - """Set common span data for MCP handlers with explicit context object.""" - # Set handler identifier - span.set_data(span_data_key, handler_name) - span.set_data(SPANDATA.MCP_METHOD_NAME, mcp_method_name) - - # Extract request_id from Context if provided - if context_obj is not None: - try: - request_id = context_obj.request_id - if request_id: - span.set_data(SPANDATA.MCP_REQUEST_ID, request_id) - except Exception: - # Silently ignore if we can't get request_id - pass - - # Set request arguments (excluding Context objects and internal keys) - for k, v in kwargs.items(): - if isinstance(v, Context): - continue - span.set_data(f"mcp.request.argument.{k}", safe_serialize(v)) - - -def _extract_context_from_kwargs(kwargs): - # type: (dict[str, Any]) -> Any - """Extract Context object from kwargs, returns None if not found.""" - for v in kwargs.values(): - if isinstance(v, Context): - return v - return None - - -def _create_span_wrapper( - original_handler, - has_context, - should_inject_context, - op, - span_name, - handler_name, - span_data_key, - mcp_method_name, -): - # type: (Callable[..., Any], bool, bool, str, str, str, str, str) -> Callable[..., Any] - """ - Creates a wrapper function that handles span creation for MCP handlers. - - This factory function eliminates code duplication by handling all - combinations of sync/async, with/without context, and with/without injection. - - Strategy Matrix: - ================ - - Case 1: has_context=True - - Original handler already has a Context parameter - - Returns: wrapper that extracts Context from kwargs and passes through - - Context flow: FastMCP → wrapper → original handler - - Request ID captured from Context - - Case 2: has_context=False, should_inject_context=True - - Original handler does NOT have a Context parameter - - Returns: wrapper with injected sentry_ctx parameter - - Context flow: FastMCP → wrapper (captures ctx) → execute_with_span - - Original handler called WITHOUT the Context (transparent injection) - - Request ID captured from injected Context - - Applies to: Tools without Context - - Case 3: has_context=False, should_inject_context=False - - Original handler does NOT have a Context parameter - - No Context injection (to avoid template registration for resources) - - Returns: simple wrapper with no Context access - - No request ID capture - - Applies to: Resources and prompts without Context - - Key Design Points: - ================== - - All cases: Use @wraps decorator to preserve function metadata - - Case 2: Add sentry_ctx: Context to __annotations__ for FastMCP injection - - Case 2: Modify __signature__ to include sentry_ctx parameter - - All cases: Use execute_with_span[_async]() for actual span creation (DRY) - - All cases: Context extraction and span data setting handled uniformly - - Args: - original_handler: The user's handler function to wrap - has_context: Whether original_handler already has a Context parameter - should_inject_context: Whether to inject sentry_ctx parameter - op: Span operation type (e.g., "mcp.tool") - span_name: Human-readable span name (e.g., "tool my_tool") - handler_name: Handler identifier (tool name, prompt name, or resource URI) - span_data_key: Span data key for handler identifier - mcp_method_name: MCP protocol method (e.g., "tools/call") - - Returns: - Wrapped function with span instrumentation and Context handling - """ - - def execute_with_span(context_obj, args, kwargs): - # type: (Any, tuple[Any, ...], dict[str, Any]) -> Any - """Execute the handler within a span context.""" - with get_start_span_function()( - op=op, - name=span_name, - origin=MCPIntegration.origin, - ) as span: - _set_span_data_with_context( - span, - handler_name, - span_data_key, - mcp_method_name, - kwargs, - context_obj, - ) - - try: - return original_handler(*args, **kwargs) - except Exception as e: - sentry_sdk.capture_exception(e) - raise - - async def execute_with_span_async(context_obj, args, kwargs): - # type: (Any, tuple[Any, ...], dict[str, Any]) -> Any - """Execute the async handler within a span context.""" - with get_start_span_function()( - op=op, - name=span_name, - origin=MCPIntegration.origin, - ) as span: - _set_span_data_with_context( - span, - handler_name, - span_data_key, - mcp_method_name, - kwargs, - context_obj, - ) - - try: - return await original_handler(*args, **kwargs) - except Exception as e: - sentry_sdk.capture_exception(e) - raise - - is_async = inspect.iscoroutinefunction(original_handler) - - if has_context: - # Original has Context - just pass through - if is_async: - - @wraps(original_handler) - async def wrapper(*args, **kwargs): - # type: (*Any, **Any) -> Any - context_obj = _extract_context_from_kwargs(kwargs) - return await execute_with_span_async(context_obj, args, kwargs) - - return wrapper - else: - - @wraps(original_handler) - def wrapper(*args, **kwargs): - # type: (*Any, **Any) -> Any - context_obj = _extract_context_from_kwargs(kwargs) - return execute_with_span(context_obj, args, kwargs) - - return wrapper - elif should_inject_context: - # Original doesn't have Context - need to inject sentry_ctx parameter - # We need to preserve the original signature and add sentry_ctx - - # Get original signature - try: - orig_sig = inspect.signature(original_handler) - except (ValueError, TypeError): - # If we can't get signature, fall back to simple wrapper - orig_sig = None - - if is_async: - if orig_sig is not None: - # Create new signature with sentry_ctx parameter added - new_params = list(orig_sig.parameters.values()) - # Add sentry_ctx as keyword-only parameter with default None - sentry_ctx_param = Parameter( - "sentry_ctx", - Parameter.KEYWORD_ONLY, - default=None, - annotation=Context, - ) - new_params.append(sentry_ctx_param) - new_sig = orig_sig.replace(parameters=new_params) - - # Create wrapper with proper signature - @wraps(original_handler) - async def outer_wrapper(*args, **kwargs): - # type: (*Any, **Any) -> Any - sentry_ctx = kwargs.pop("sentry_ctx", None) - return await execute_with_span_async(sentry_ctx, args, kwargs) - - # Set the proper signature - outer_wrapper.__signature__ = new_sig # type: ignore[attr-defined] - else: - # Fallback: use simple wrapper - @wraps(original_handler) - async def outer_wrapper(*args, **kwargs): - # type: (*Any, **Any) -> Any - sentry_ctx = kwargs.pop("sentry_ctx", None) - return await execute_with_span_async(sentry_ctx, args, kwargs) - - # Add Context annotation - outer_wrapper.__annotations__ = getattr( - original_handler, "__annotations__", {} - ).copy() - outer_wrapper.__annotations__["sentry_ctx"] = Context - - return outer_wrapper - else: - if orig_sig is not None: - # Create new signature with sentry_ctx parameter added - new_params = list(orig_sig.parameters.values()) - # Add sentry_ctx as keyword-only parameter with default None - sentry_ctx_param = Parameter( - "sentry_ctx", - Parameter.KEYWORD_ONLY, - default=None, - annotation=Context, - ) - new_params.append(sentry_ctx_param) - new_sig = orig_sig.replace(parameters=new_params) - - # Create wrapper with proper signature - @wraps(original_handler) - def outer_wrapper(*args, **kwargs): - # type: (*Any, **Any) -> Any - sentry_ctx = kwargs.pop("sentry_ctx", None) - return execute_with_span(sentry_ctx, args, kwargs) - - # Set the proper signature - outer_wrapper.__signature__ = new_sig # type: ignore[attr-defined] - else: - # Fallback: use simple wrapper - @wraps(original_handler) - def outer_wrapper(*args, **kwargs): - # type: (*Any, **Any) -> Any - sentry_ctx = kwargs.pop("sentry_ctx", None) - return execute_with_span(sentry_ctx, args, kwargs) - - # Add Context annotation - outer_wrapper.__annotations__ = getattr( - original_handler, "__annotations__", {} - ).copy() - outer_wrapper.__annotations__["sentry_ctx"] = Context - - return outer_wrapper - else: - # No context and no injection - simple pass-through wrapper - if is_async: - - @wraps(original_handler) - async def wrapper(*args, **kwargs): - # type: (*Any, **Any) -> Any - return await execute_with_span_async(None, args, kwargs) - - return wrapper - else: - - @wraps(original_handler) - def wrapper(*args, **kwargs): - # type: (*Any, **Any) -> Any - return execute_with_span(None, args, kwargs) - - return wrapper - - -def wrap_handler(original_handler, handler_type, handler_name): - # type: (Callable[..., Any], str, str) -> Callable[..., Any] - """ - Wraps a handler function to create spans and capture errors. - - Context injection strategy: - - Tools without Context: Inject sentry_ctx to capture request_id - - Resources/prompts without Context: No injection (avoids template registration) - - Handlers with Context: Use existing Context to capture request_id - - This ensures resources are registered correctly (not as templates) while - still capturing request_id for tools. - - Args: - original_handler: The original handler function to wrap - handler_type: Type of handler ('tool', 'prompt', or 'resource') - handler_name: Name or identifier of the handler - - Returns: - Wrapped handler function - """ - op, span_data_key, span_name, mcp_method_name = _get_span_config( - handler_type, handler_name - ) - - # Check if the original handler already has a Context parameter - has_context = find_context_parameter(original_handler) is not None - - # For now, only inject context for tools to avoid resources being registered as templates - # TODO: Find a better way to capture request_id for resources/prompts without Context - should_inject_context = handler_type == "tool" and not has_context - - return _create_span_wrapper( - original_handler, - has_context, - should_inject_context, - op, - span_name, - handler_name, - span_data_key, - mcp_method_name, - ) - - -def patch_fastmcp_server(): - # type: () -> None - """ - Patches the mcp.server.fastmcp.FastMCP class to instrument handler execution. - """ - from mcp.server.fastmcp import FastMCP - - # Patch tool decorator - original_tool = FastMCP.tool - - def patched_tool(self, name=None, **kwargs): - # type: (FastMCP, str | None, **Any) -> Callable[[Callable[..., Any]], Callable[..., Any]] - def decorator(func): - # type: (Callable[..., Any]) -> Callable[..., Any] - # Use provided name or function name - tool_name = name if name else getattr(func, "__name__", "unknown") - wrapped_func = wrap_handler(func, "tool", tool_name) - - # Call the original decorator with the wrapped function - return original_tool(self, name=name, **kwargs)(wrapped_func) - - return decorator - - FastMCP.tool = patched_tool # type: ignore - - # Patch prompt decorator - original_prompt = FastMCP.prompt - - def patched_prompt(self, name=None, **kwargs): - # type: (FastMCP, str | None, **Any) -> Callable[[Callable[..., Any]], Callable[..., Any]] - def decorator(func): - # type: (Callable[..., Any]) -> Callable[..., Any] - # Use provided name or function name - prompt_name = name if name else getattr(func, "__name__", "unknown") - wrapped_func = wrap_handler(func, "prompt", prompt_name) - - # Call the original decorator with the wrapped function - return original_prompt(self, name=name, **kwargs)(wrapped_func) - - return decorator - - FastMCP.prompt = patched_prompt # type: ignore - - # Patch resource decorator - original_resource = FastMCP.resource - - def patched_resource(self, uri, **kwargs): - # type: (FastMCP, str, **Any) -> Callable[[Callable[..., Any]], Callable[..., Any]] - def decorator(func): - # type: (Callable[..., Any]) -> Callable[..., Any] - # Use the URI as the identifier - wrapped_func = wrap_handler(func, "resource", uri) - - # Call the original decorator with the wrapped function - return original_resource(self, uri, **kwargs)(wrapped_func) - - return decorator - - FastMCP.resource = patched_resource # type: ignore diff --git a/sentry_sdk/integrations/mcp/lowlevel.py b/sentry_sdk/integrations/mcp/lowlevel.py index e129d48934..0bc15ef384 100644 --- a/sentry_sdk/integrations/mcp/lowlevel.py +++ b/sentry_sdk/integrations/mcp/lowlevel.py @@ -10,6 +10,7 @@ from sentry_sdk.ai.utils import get_start_span_function from sentry_sdk.consts import OP, SPANDATA from sentry_sdk.integrations.mcp import MCPIntegration +from sentry_sdk.integrations.mcp.transport import detect_mcp_transport_from_context from sentry_sdk.utils import safe_serialize from mcp.server.lowlevel import Server @@ -21,128 +22,171 @@ def _get_span_config(handler_type, handler_name): - # type: (str, str) -> tuple[str, str, str] + # type: (str, str) -> tuple[str, str, str, str] """ Get span configuration based on handler type. Returns: - Tuple of (span_data_key, span_name, mcp_method_name) + Tuple of (span_data_key, span_name, mcp_method_name, result_data_key) """ if handler_type == "tool": span_data_key = SPANDATA.MCP_TOOL_NAME mcp_method_name = "tools/call" + result_data_key = SPANDATA.MCP_TOOL_RESULT_CONTENT elif handler_type == "prompt": span_data_key = SPANDATA.MCP_PROMPT_NAME mcp_method_name = "prompts/get" + result_data_key = SPANDATA.MCP_PROMPT_RESULT_MESSAGE_CONTENT else: # resource span_data_key = SPANDATA.MCP_RESOURCE_URI mcp_method_name = "resources/read" + result_data_key = SPANDATA.MCP_RESOURCE_RESULT_CONTENT span_name = f"{handler_type} {handler_name}" - return span_data_key, span_name, mcp_method_name + return span_data_key, span_name, mcp_method_name, result_data_key -def _set_span_data( - span, handler_name, span_data_key, mcp_method_name, kwargs, request_id=None +def _set_span_input_data( + span, handler_name, span_data_key, mcp_method_name, arguments, request_id=None ): # type: (Any, str, str, str, dict[str, Any], str | None) -> None - """Set common span data for MCP handlers.""" + """Set input span data for MCP handlers.""" # Set handler identifier span.set_data(span_data_key, handler_name) span.set_data(SPANDATA.MCP_METHOD_NAME, mcp_method_name) + # Detect and set transport if available + try: + ctx = request_ctx.get() + transport = detect_mcp_transport_from_context(ctx) + if transport is not None: + span.set_data(SPANDATA.MCP_TRANSPORT, transport) + except LookupError: + # No request context available - likely stdio + span.set_data(SPANDATA.MCP_TRANSPORT, "pipe") + # Set request_id if provided if request_id: span.set_data(SPANDATA.MCP_REQUEST_ID, request_id) # Set request arguments (excluding common request context objects) - for k, v in kwargs.items(): + for k, v in arguments.items(): span.set_data(f"mcp.request.argument.{k}", safe_serialize(v)) -def wrap_handler(original_handler, handler_type, handler_name): - # type: (Callable[..., Any], str, str) -> Callable[..., Any] +def _extract_tool_result_content(result): + # type: (Any) -> Any """ - Wraps a handler function to create spans and capture errors. - - Args: - original_handler: The original handler function to wrap - handler_type: Type of handler ('tool', 'prompt', or 'resource') - handler_name: Name or identifier of the handler + Extract meaningful content from MCP tool result. - Returns: - Wrapped handler function + Tool handlers can return: + - tuple (UnstructuredContent, StructuredContent): Return the structured content (dict) + - dict (StructuredContent): Return as-is + - Iterable (UnstructuredContent): Extract text from content blocks """ - span_data_key, span_name, mcp_method_name = _get_span_config( - handler_type, handler_name - ) - - if inspect.iscoroutinefunction(original_handler): - - @wraps(original_handler) - async def async_wrapper(*args, **kwargs): - # type: (*Any, **Any) -> Any - with get_start_span_function()( - op=OP.MCP_SERVER, - name=span_name, - origin=MCPIntegration.origin, - ) as span: - # Extract request_id from RequestContext context variable - request_id = None - try: - ctx = request_ctx.get() - request_id = ctx.request_id - except LookupError: - # Not in a request context, request_id will remain None - pass - _set_span_data( - span, - handler_name, - span_data_key, - mcp_method_name, - kwargs, - request_id, - ) - try: - return await original_handler(*args, **kwargs) - except Exception as e: - sentry_sdk.capture_exception(e) - raise - - return async_wrapper + if result is None: + return None + + # Handle CombinationContent: tuple of (UnstructuredContent, StructuredContent) + if isinstance(result, tuple) and len(result) == 2: + # Return the structured content (2nd element) + return result[1] + + # Handle StructuredContent: dict + if isinstance(result, dict): + return result + + # Handle UnstructuredContent: iterable of ContentBlock objects + # Try to extract text content + if hasattr(result, "__iter__") and not isinstance(result, (str, bytes, dict)): + texts = [] + try: + for item in result: + # Try to get text attribute from ContentBlock objects + if hasattr(item, "text"): + texts.append(item.text) + elif isinstance(item, dict) and "text" in item: + texts.append(item["text"]) + except Exception: + # If extraction fails, return the original + return result + return " ".join(texts) if texts else result + + return result + + +def _set_span_output_data(span, result, result_data_key, handler_type): + # type: (Any, Any, str, str) -> None + """Set output span data for MCP handlers.""" + if result is None: + return + + # For tools, extract the meaningful content + if handler_type == "tool": + extracted = _extract_tool_result_content(result) + if extracted is not None: + span.set_data(result_data_key, safe_serialize(extracted)) + # Set content count if result is a dict + if isinstance(extracted, dict): + span.set_data(SPANDATA.MCP_TOOL_RESULT_CONTENT_COUNT, len(extracted)) + elif handler_type == "prompt": + # For prompts, count messages and set role/content only for single-message prompts + try: + messages = None + message_count = 0 + + # Check if result has messages attribute (GetPromptResult) + if hasattr(result, "messages") and result.messages: + messages = result.messages + message_count = len(messages) + # Also check if result is a dict with messages + elif isinstance(result, dict) and result.get("messages"): + messages = result["messages"] + message_count = len(messages) + + # Always set message count if we found messages + if message_count > 0: + span.set_data(SPANDATA.MCP_PROMPT_RESULT_MESSAGE_COUNT, message_count) + + # Only set role and content for single-message prompts + if message_count == 1: + first_message = messages[0] + # Extract role + role = None + if hasattr(first_message, "role"): + role = first_message.role + elif isinstance(first_message, dict) and "role" in first_message: + role = first_message["role"] + + if role: + span.set_data(SPANDATA.MCP_PROMPT_RESULT_MESSAGE_ROLE, role) + + # Extract content text + content_text = None + if hasattr(first_message, "content"): + msg_content = first_message.content + # Content can be a TextContent object or similar + if hasattr(msg_content, "text"): + content_text = msg_content.text + elif isinstance(msg_content, dict) and "text" in msg_content: + content_text = msg_content["text"] + elif isinstance(msg_content, str): + content_text = msg_content + elif isinstance(first_message, dict) and "content" in first_message: + msg_content = first_message["content"] + if isinstance(msg_content, dict) and "text" in msg_content: + content_text = msg_content["text"] + elif isinstance(msg_content, str): + content_text = msg_content + + if content_text: + span.set_data(result_data_key, content_text) + except Exception: + # Silently ignore if we can't extract message info, but still serialize result + span.set_data(result_data_key, safe_serialize(result)) else: - - @wraps(original_handler) - def sync_wrapper(*args, **kwargs): - # type: (*Any, **Any) -> Any - with get_start_span_function()( - op=OP.MCP_SERVER, - name=span_name, - origin=MCPIntegration.origin, - ) as span: - # Extract request_id from RequestContext context variable - request_id = None - try: - ctx = request_ctx.get() - request_id = ctx.request_id - except LookupError: - # Not in a request context, request_id will remain None - pass - _set_span_data( - span, - handler_name, - span_data_key, - mcp_method_name, - kwargs, - request_id, - ) - try: - return original_handler(*args, **kwargs) - except Exception as e: - sentry_sdk.capture_exception(e) - raise - - return sync_wrapper + # For resources, serialize directly + span.set_data(result_data_key, safe_serialize(result)) def patch_lowlevel_server(): @@ -158,12 +202,80 @@ def patched_call_tool(self, **kwargs): # type: (Server, **Any) -> Callable[[Callable[..., Any]], Callable[..., Any]] def decorator(func): # type: (Callable[..., Any]) -> Callable[..., Any] - # Extract tool name from the function - tool_name = getattr(func, "__name__", "unknown") - wrapped_func = wrap_handler(func, "tool", tool_name) + if inspect.iscoroutinefunction(func): - # Call the original decorator with the wrapped function - return original_call_tool(self, **kwargs)(wrapped_func) + @wraps(func) + async def async_wrapper(tool_name, arguments): + # type: (str, Any) -> Any + span_data_key, span_name, mcp_method_name, result_data_key = ( + _get_span_config("tool", tool_name) + ) + with get_start_span_function()( + op=OP.MCP_SERVER, + name=span_name, + origin=MCPIntegration.origin, + ) as span: + request_id = None + try: + ctx = request_ctx.get() + request_id = ctx.request_id + except LookupError: + pass + _set_span_input_data( + span, + tool_name, + span_data_key, + mcp_method_name, + arguments, + request_id, + ) + try: + result = await func(tool_name, arguments) + _set_span_output_data(span, result, result_data_key, "tool") + return result + except Exception as e: + span.set_data(SPANDATA.MCP_TOOL_RESULT_IS_ERROR, True) + sentry_sdk.capture_exception(e) + raise + + return original_call_tool(self, **kwargs)(async_wrapper) + else: + + @wraps(func) + def sync_wrapper(tool_name, arguments): + # type: (str, Any) -> Any + span_data_key, span_name, mcp_method_name, result_data_key = ( + _get_span_config("tool", tool_name) + ) + with get_start_span_function()( + op=OP.MCP_SERVER, + name=span_name, + origin=MCPIntegration.origin, + ) as span: + request_id = None + try: + ctx = request_ctx.get() + request_id = ctx.request_id + except LookupError: + pass + _set_span_input_data( + span, + tool_name, + span_data_key, + mcp_method_name, + arguments, + request_id, + ) + try: + result = func(tool_name, arguments) + _set_span_output_data(span, result, result_data_key, "tool") + return result + except Exception as e: + span.set_data(SPANDATA.MCP_TOOL_RESULT_IS_ERROR, True) + sentry_sdk.capture_exception(e) + raise + + return original_call_tool(self, **kwargs)(sync_wrapper) return decorator @@ -176,26 +288,90 @@ def patched_get_prompt(self): # type: (Server) -> Callable[[Callable[..., Any]], Callable[..., Any]] def decorator(func): # type: (Callable[..., Any]) -> Callable[..., Any] - # The handler receives (name, arguments) as parameters - # We need to extract the name from the call if inspect.iscoroutinefunction(func): @wraps(func) - async def async_name_wrapper(name, arguments): + async def async_wrapper(name, arguments): # type: (str, Any) -> Any - wrapped = wrap_handler(func, "prompt", name) - return await wrapped(name, arguments) - - return original_get_prompt(self)(async_name_wrapper) + span_data_key, span_name, mcp_method_name, result_data_key = ( + _get_span_config("prompt", name) + ) + with get_start_span_function()( + op=OP.MCP_SERVER, + name=span_name, + origin=MCPIntegration.origin, + ) as span: + request_id = None + try: + ctx = request_ctx.get() + request_id = ctx.request_id + except LookupError: + pass + # Include name in arguments dict for span data + args_with_name = {"name": name} + if arguments: + args_with_name.update(arguments) + _set_span_input_data( + span, + name, + span_data_key, + mcp_method_name, + args_with_name, + request_id, + ) + try: + result = await func(name, arguments) + _set_span_output_data( + span, result, result_data_key, "prompt" + ) + return result + except Exception as e: + sentry_sdk.capture_exception(e) + raise + + return original_get_prompt(self)(async_wrapper) else: @wraps(func) - def sync_name_wrapper(name, arguments): + def sync_wrapper(name, arguments): # type: (str, Any) -> Any - wrapped = wrap_handler(func, "prompt", name) - return wrapped(name, arguments) - - return original_get_prompt(self)(sync_name_wrapper) + span_data_key, span_name, mcp_method_name, result_data_key = ( + _get_span_config("prompt", name) + ) + with get_start_span_function()( + op=OP.MCP_SERVER, + name=span_name, + origin=MCPIntegration.origin, + ) as span: + request_id = None + try: + ctx = request_ctx.get() + request_id = ctx.request_id + except LookupError: + pass + # Include name in arguments dict for span data + args_with_name = {"name": name} + if arguments: + args_with_name.update(arguments) + _set_span_input_data( + span, + name, + span_data_key, + mcp_method_name, + args_with_name, + request_id, + ) + try: + result = func(name, arguments) + _set_span_output_data( + span, result, result_data_key, "prompt" + ) + return result + except Exception as e: + sentry_sdk.capture_exception(e) + raise + + return original_get_prompt(self)(sync_wrapper) return decorator @@ -208,27 +384,84 @@ def patched_read_resource(self): # type: (Server) -> Callable[[Callable[..., Any]], Callable[..., Any]] def decorator(func): # type: (Callable[..., Any]) -> Callable[..., Any] - # The handler receives a URI as a parameter if inspect.iscoroutinefunction(func): @wraps(func) - async def async_uri_wrapper(uri): + async def async_wrapper(uri): # type: (Any) -> Any uri_str = str(uri) if uri else "unknown" - wrapped = wrap_handler(func, "resource", uri_str) - return await wrapped(uri) - - return original_read_resource(self)(async_uri_wrapper) + span_data_key, span_name, mcp_method_name, result_data_key = ( + _get_span_config("resource", uri_str) + ) + with get_start_span_function()( + op=OP.MCP_SERVER, + name=span_name, + origin=MCPIntegration.origin, + ) as span: + request_id = None + try: + ctx = request_ctx.get() + request_id = ctx.request_id + except LookupError: + pass + _set_span_input_data( + span, + uri_str, + span_data_key, + mcp_method_name, + {}, + request_id, + ) + try: + result = await func(uri) + _set_span_output_data( + span, result, result_data_key, "resource" + ) + return result + except Exception as e: + sentry_sdk.capture_exception(e) + raise + + return original_read_resource(self)(async_wrapper) else: @wraps(func) - def sync_uri_wrapper(uri): + def sync_wrapper(uri): # type: (Any) -> Any uri_str = str(uri) if uri else "unknown" - wrapped = wrap_handler(func, "resource", uri_str) - return wrapped(uri) - - return original_read_resource(self)(sync_uri_wrapper) + span_data_key, span_name, mcp_method_name, result_data_key = ( + _get_span_config("resource", uri_str) + ) + with get_start_span_function()( + op=OP.MCP_SERVER, + name=span_name, + origin=MCPIntegration.origin, + ) as span: + request_id = None + try: + ctx = request_ctx.get() + request_id = ctx.request_id + except LookupError: + pass + _set_span_input_data( + span, + uri_str, + span_data_key, + mcp_method_name, + {}, + request_id, + ) + try: + result = func(uri) + _set_span_output_data( + span, result, result_data_key, "resource" + ) + return result + except Exception as e: + sentry_sdk.capture_exception(e) + raise + + return original_read_resource(self)(sync_wrapper) return decorator diff --git a/sentry_sdk/integrations/mcp/transport.py b/sentry_sdk/integrations/mcp/transport.py new file mode 100644 index 0000000000..9f5e36ecd6 --- /dev/null +++ b/sentry_sdk/integrations/mcp/transport.py @@ -0,0 +1,48 @@ +""" +Transport detection for MCP servers. + +This module provides functionality to detect which transport method is being used +by an MCP server (stdio/pipe vs HTTP-based transports like SSE/WebSocket). + +Detection is done lazily by inspecting the request context at runtime: +- If there's an HTTP request context (SSE/WebSocket), transport is "tcp" +- If there's no request context (stdio), transport is "pipe" +""" + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from typing import Optional, Any + + +def detect_mcp_transport_from_context(request_ctx): + # type: (Any) -> Optional[str] + """ + Detect MCP transport type from the request context. + + Args: + request_ctx: The MCP request context object (from request_ctx.get()) + + Returns: + "pipe" for stdio transport, "tcp" for HTTP-based transports (SSE/WebSocket), + or None if transport type cannot be determined. + + Detection logic: + - If request_ctx has a 'request' attribute with a value, it's HTTP-based → "tcp" + - If request_ctx exists but has no 'request' or request is None, it's stdio → "pipe" + - If we can't determine, return None + """ + try: + # Check if there's a request object in the context + # SSE and WebSocket transports set this via ServerMessageMetadata + if hasattr(request_ctx, "request") and request_ctx.request is not None: + # This is SSE or WebSocket (HTTP-based) + return "tcp" + elif hasattr(request_ctx, "request"): + # Context exists but no HTTP request - this is stdio + return "pipe" + except Exception: + # If anything goes wrong, return None + pass + + return None From 609489d2aa24f6bec19ffc9b76793f7c8c41c0a3 Mon Sep 17 00:00:00 2001 From: Fabian Schindler Date: Thu, 16 Oct 2025 15:39:39 +0200 Subject: [PATCH 04/18] fix: resource content shall not be stored in an attribute, instead their URI should be --- sentry_sdk/consts.py | 6 ++--- sentry_sdk/integrations/mcp/lowlevel.py | 35 +++++++++++++++++++------ 2 files changed, 30 insertions(+), 11 deletions(-) diff --git a/sentry_sdk/consts.py b/sentry_sdk/consts.py index 3f44c476d7..2fa7798b46 100644 --- a/sentry_sdk/consts.py +++ b/sentry_sdk/consts.py @@ -815,10 +815,10 @@ class SPANDATA: Example: 1, 3 """ - MCP_RESOURCE_RESULT_CONTENT = "mcp.resource.result.content" + MCP_RESOURCE_PROTOCOL = "mcp.resource.protocol" """ - The result/output content from an MCP resource read. - Example: "File contents..." + The protocol/scheme of the MCP resource URI. + Example: "file", "http", "https" """ MCP_TRANSPORT = "mcp.transport" diff --git a/sentry_sdk/integrations/mcp/lowlevel.py b/sentry_sdk/integrations/mcp/lowlevel.py index 0bc15ef384..76fa041ade 100644 --- a/sentry_sdk/integrations/mcp/lowlevel.py +++ b/sentry_sdk/integrations/mcp/lowlevel.py @@ -21,13 +21,14 @@ from typing import Any, Callable -def _get_span_config(handler_type, handler_name): - # type: (str, str) -> tuple[str, str, str, str] +def _get_span_config(handler_type, item_name): + # type: (str, str) -> tuple[str, str, str, str | None] """ Get span configuration based on handler type. Returns: Tuple of (span_data_key, span_name, mcp_method_name, result_data_key) + Note: result_data_key is None for resources """ if handler_type == "tool": span_data_key = SPANDATA.MCP_TOOL_NAME @@ -40,9 +41,9 @@ def _get_span_config(handler_type, handler_name): else: # resource span_data_key = SPANDATA.MCP_RESOURCE_URI mcp_method_name = "resources/read" - result_data_key = SPANDATA.MCP_RESOURCE_RESULT_CONTENT + result_data_key = None # Resources don't capture result content - span_name = f"{handler_type} {handler_name}" + span_name = f"{mcp_method_name} {item_name}" return span_data_key, span_name, mcp_method_name, result_data_key @@ -116,7 +117,7 @@ def _extract_tool_result_content(result): def _set_span_output_data(span, result, result_data_key, handler_type): - # type: (Any, Any, str, str) -> None + # type: (Any, Any, str | None, str) -> None """Set output span data for MCP handlers.""" if result is None: return @@ -184,9 +185,7 @@ def _set_span_output_data(span, result, result_data_key, handler_type): except Exception: # Silently ignore if we can't extract message info, but still serialize result span.set_data(result_data_key, safe_serialize(result)) - else: - # For resources, serialize directly - span.set_data(result_data_key, safe_serialize(result)) + # Resources don't capture result content (result_data_key is None) def patch_lowlevel_server(): @@ -390,6 +389,13 @@ def decorator(func): async def async_wrapper(uri): # type: (Any) -> Any uri_str = str(uri) if uri else "unknown" + # Extract protocol/scheme from URI + protocol = None + if hasattr(uri, "scheme"): + protocol = uri.scheme + elif uri_str and "://" in uri_str: + protocol = uri_str.split("://")[0] + span_data_key, span_name, mcp_method_name, result_data_key = ( _get_span_config("resource", uri_str) ) @@ -412,6 +418,9 @@ async def async_wrapper(uri): {}, request_id, ) + # Set protocol if found + if protocol: + span.set_data(SPANDATA.MCP_RESOURCE_PROTOCOL, protocol) try: result = await func(uri) _set_span_output_data( @@ -429,6 +438,13 @@ async def async_wrapper(uri): def sync_wrapper(uri): # type: (Any) -> Any uri_str = str(uri) if uri else "unknown" + # Extract protocol/scheme from URI + protocol = None + if hasattr(uri, "scheme"): + protocol = uri.scheme + elif uri_str and "://" in uri_str: + protocol = uri_str.split("://")[0] + span_data_key, span_name, mcp_method_name, result_data_key = ( _get_span_config("resource", uri_str) ) @@ -451,6 +467,9 @@ def sync_wrapper(uri): {}, request_id, ) + # Set protocol if found + if protocol: + span.set_data(SPANDATA.MCP_RESOURCE_PROTOCOL, protocol) try: result = func(uri) _set_span_output_data( From ae9d325c203221a1de2070a96c7040fea7c5d5b9 Mon Sep 17 00:00:00 2001 From: Fabian Schindler Date: Mon, 20 Oct 2025 13:43:40 +0200 Subject: [PATCH 05/18] feat: detecting transport and session id --- sentry_sdk/consts.py | 6 + sentry_sdk/integrations/mcp/__init__.py | 6 + sentry_sdk/integrations/mcp/lowlevel.py | 541 ++++++++++++----------- sentry_sdk/integrations/mcp/transport.py | 53 ++- 4 files changed, 332 insertions(+), 274 deletions(-) diff --git a/sentry_sdk/consts.py b/sentry_sdk/consts.py index 2fa7798b46..d5bc838235 100644 --- a/sentry_sdk/consts.py +++ b/sentry_sdk/consts.py @@ -827,6 +827,12 @@ class SPANDATA: Example: "pipe" (stdio), "tcp" (HTTP/WebSocket/SSE) """ + MCP_SESSION_ID = "mcp.session.id" + """ + The session identifier for the MCP connection. + Example: "a1b2c3d4e5f6" + """ + class SPANSTATUS: """ diff --git a/sentry_sdk/integrations/mcp/__init__.py b/sentry_sdk/integrations/mcp/__init__.py index 0a4e9fa704..efd5778c76 100644 --- a/sentry_sdk/integrations/mcp/__init__.py +++ b/sentry_sdk/integrations/mcp/__init__.py @@ -27,9 +27,15 @@ def setup_once(): Patches MCP server classes to instrument handler execution. """ from sentry_sdk.integrations.mcp.lowlevel import patch_lowlevel_server + from sentry_sdk.integrations.mcp.transport import ( + patch_streamable_http_transport, + ) # Patch server classes to instrument handlers patch_lowlevel_server() + # Patch HTTP transport to track session IDs + patch_streamable_http_transport() + __all__ = ["MCPIntegration"] diff --git a/sentry_sdk/integrations/mcp/lowlevel.py b/sentry_sdk/integrations/mcp/lowlevel.py index 76fa041ade..a5b7a3c00d 100644 --- a/sentry_sdk/integrations/mcp/lowlevel.py +++ b/sentry_sdk/integrations/mcp/lowlevel.py @@ -10,7 +10,10 @@ from sentry_sdk.ai.utils import get_start_span_function from sentry_sdk.consts import OP, SPANDATA from sentry_sdk.integrations.mcp import MCPIntegration -from sentry_sdk.integrations.mcp.transport import detect_mcp_transport_from_context +from sentry_sdk.integrations.mcp.transport import ( + detect_mcp_transport_from_context, + mcp_session_id_ctx, +) from sentry_sdk.utils import safe_serialize from mcp.server.lowlevel import Server @@ -66,6 +69,15 @@ def _set_span_input_data( # No request context available - likely stdio span.set_data(SPANDATA.MCP_TRANSPORT, "pipe") + # Extract session ID from context variable if available (HTTP transport) + try: + session_id = mcp_session_id_ctx.get() + if session_id: + span.set_data(SPANDATA.MCP_SESSION_ID, session_id) + except Exception: + # Session ID not available or transport module not imported + pass + # Set request_id if provided if request_id: span.set_data(SPANDATA.MCP_REQUEST_ID, request_id) @@ -188,95 +200,266 @@ def _set_span_output_data(span, result, result_data_key, handler_type): # Resources don't capture result content (result_data_key is None) +def _prepare_handler_data(handler_type, original_args): + # type: (str, tuple[Any, ...]) -> tuple[str, dict[str, Any], str, str, str, str | None] + """ + Prepare common handler data for both async and sync wrappers. + + Returns: + Tuple of (handler_name, arguments, span_data_key, span_name, mcp_method_name, result_data_key) + """ + # Extract handler-specific data based on handler type + if handler_type == "tool": + handler_name = original_args[0] # tool_name + arguments = original_args[1] if len(original_args) > 1 else {} + elif handler_type == "prompt": + handler_name = original_args[0] # name + arguments = original_args[1] if len(original_args) > 1 else {} + # Include name in arguments dict for span data + arguments = {"name": handler_name, **(arguments or {})} + else: # resource + uri = original_args[0] + handler_name = str(uri) if uri else "unknown" + arguments = {} + + # Get span configuration + span_data_key, span_name, mcp_method_name, result_data_key = _get_span_config( + handler_type, handler_name + ) + + return ( + handler_name, + arguments, + span_data_key, + span_name, + mcp_method_name, + result_data_key, + ) + + +async def _async_handler_wrapper(handler_type, func, original_args): + # type: (str, Callable[..., Any], tuple[Any, ...]) -> Any + """ + Async wrapper for MCP handlers. + + Args: + handler_type: "tool", "prompt", or "resource" + func: The async handler function to wrap + original_args: Original arguments passed to the handler + """ + ( + handler_name, + arguments, + span_data_key, + span_name, + mcp_method_name, + result_data_key, + ) = _prepare_handler_data(handler_type, original_args) + + # Start span and execute + with get_start_span_function()( + op=OP.MCP_SERVER, + name=span_name, + origin=MCPIntegration.origin, + ) as span: + # Get request ID from context + request_id = None + try: + ctx = request_ctx.get() + request_id = ctx.request_id + except LookupError: + pass + + # Set input span data + _set_span_input_data( + span, + handler_name, + span_data_key, + mcp_method_name, + arguments, + request_id, + ) + + # For resources, extract and set protocol + if handler_type == "resource": + uri = original_args[0] + 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 + result = await func(*original_args) + _set_span_output_data(span, result, result_data_key, handler_type) + return 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 + + +def _sync_handler_wrapper(handler_type, func, original_args): + # type: (str, Callable[..., Any], tuple[Any, ...]) -> Any + """ + Sync wrapper for MCP handlers. + + Args: + handler_type: "tool", "prompt", or "resource" + func: The sync handler function to wrap + original_args: Original arguments passed to the handler + """ + ( + handler_name, + arguments, + span_data_key, + span_name, + mcp_method_name, + result_data_key, + ) = _prepare_handler_data(handler_type, original_args) + + # Start span and execute + with get_start_span_function()( + op=OP.MCP_SERVER, + name=span_name, + origin=MCPIntegration.origin, + ) as span: + # Get request ID from context + request_id = None + try: + ctx = request_ctx.get() + request_id = ctx.request_id + except LookupError: + pass + + # Set input span data + _set_span_input_data( + span, + handler_name, + span_data_key, + mcp_method_name, + arguments, + request_id, + ) + + # For resources, extract and set protocol + if handler_type == "resource": + uri = original_args[0] + 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 sync handler + result = func(*original_args) + _set_span_output_data(span, result, result_data_key, handler_type) + return 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 + + +def _create_instrumented_handler(handler_type, func): + # type: (str, Callable[..., Any]) -> Callable[..., Any] + """ + Create an instrumented version of a handler function (async or sync). + + This function wraps the user's handler with a runtime wrapper that will create + Sentry spans and capture metrics when the handler is actually called. + + The wrapper preserves the async/sync nature of the original function, which is + critical for Python's async/await to work correctly. + + Args: + handler_type: "tool", "prompt", or "resource" - determines span configuration + func: The handler function to instrument (async or sync) + + Returns: + A wrapped version of func that creates Sentry spans on execution + """ + if inspect.iscoroutinefunction(func): + + @wraps(func) + async def async_wrapper(*args): + # type: (*Any) -> Any + return await _async_handler_wrapper(handler_type, func, args) + + return async_wrapper + else: + + @wraps(func) + def sync_wrapper(*args): + # type: (*Any) -> Any + return _sync_handler_wrapper(handler_type, func, args) + + return sync_wrapper + + +def _create_instrumented_decorator( + original_decorator, handler_type, *decorator_args, **decorator_kwargs +): + # type: (Callable[..., Any], str, *Any, **Any) -> Callable[..., Any] + """ + Create an instrumented version of an MCP decorator. + + This function intercepts MCP decorators (like @server.call_tool()) and injects + Sentry instrumentation into the handler registration flow. The returned decorator + will: + 1. Receive the user's handler function + 2. Wrap it with instrumentation via _create_instrumented_handler + 3. Pass the instrumented version to the original MCP decorator + + This ensures that when the handler is called at runtime, it's already wrapped + with Sentry spans and metrics collection. + + Args: + original_decorator: The original MCP decorator method (e.g., Server.call_tool) + handler_type: "tool", "prompt", or "resource" - determines span configuration + decorator_args: Positional arguments to pass to the original decorator (e.g., self) + decorator_kwargs: Keyword arguments to pass to the original decorator + + Returns: + A decorator function that instruments handlers before registering them + """ + + def instrumented_decorator(func): + # type: (Callable[..., Any]) -> Callable[..., Any] + # First wrap the handler with instrumentation + instrumented_func = _create_instrumented_handler(handler_type, func) + # Then register it with the original MCP decorator + return original_decorator(*decorator_args, **decorator_kwargs)( + instrumented_func + ) + + return instrumented_decorator + + def patch_lowlevel_server(): # type: () -> None """ Patches the mcp.server.lowlevel.Server class to instrument handler execution. """ - # Patch call_tool decorator original_call_tool = Server.call_tool def patched_call_tool(self, **kwargs): # type: (Server, **Any) -> Callable[[Callable[..., Any]], Callable[..., Any]] - def decorator(func): - # type: (Callable[..., Any]) -> Callable[..., Any] - if inspect.iscoroutinefunction(func): - - @wraps(func) - async def async_wrapper(tool_name, arguments): - # type: (str, Any) -> Any - span_data_key, span_name, mcp_method_name, result_data_key = ( - _get_span_config("tool", tool_name) - ) - with get_start_span_function()( - op=OP.MCP_SERVER, - name=span_name, - origin=MCPIntegration.origin, - ) as span: - request_id = None - try: - ctx = request_ctx.get() - request_id = ctx.request_id - except LookupError: - pass - _set_span_input_data( - span, - tool_name, - span_data_key, - mcp_method_name, - arguments, - request_id, - ) - try: - result = await func(tool_name, arguments) - _set_span_output_data(span, result, result_data_key, "tool") - return result - except Exception as e: - span.set_data(SPANDATA.MCP_TOOL_RESULT_IS_ERROR, True) - sentry_sdk.capture_exception(e) - raise - - return original_call_tool(self, **kwargs)(async_wrapper) - else: - - @wraps(func) - def sync_wrapper(tool_name, arguments): - # type: (str, Any) -> Any - span_data_key, span_name, mcp_method_name, result_data_key = ( - _get_span_config("tool", tool_name) - ) - with get_start_span_function()( - op=OP.MCP_SERVER, - name=span_name, - origin=MCPIntegration.origin, - ) as span: - request_id = None - try: - ctx = request_ctx.get() - request_id = ctx.request_id - except LookupError: - pass - _set_span_input_data( - span, - tool_name, - span_data_key, - mcp_method_name, - arguments, - request_id, - ) - try: - result = func(tool_name, arguments) - _set_span_output_data(span, result, result_data_key, "tool") - return result - except Exception as e: - span.set_data(SPANDATA.MCP_TOOL_RESULT_IS_ERROR, True) - sentry_sdk.capture_exception(e) - raise - - return original_call_tool(self, **kwargs)(sync_wrapper) - - return decorator + """Patched version of Server.call_tool that adds Sentry instrumentation.""" + return lambda func: _create_instrumented_decorator( + original_call_tool, "tool", self, **kwargs + )(func) Server.call_tool = patched_call_tool # type: ignore @@ -285,94 +468,10 @@ def sync_wrapper(tool_name, arguments): def patched_get_prompt(self): # type: (Server) -> Callable[[Callable[..., Any]], Callable[..., Any]] - def decorator(func): - # type: (Callable[..., Any]) -> Callable[..., Any] - if inspect.iscoroutinefunction(func): - - @wraps(func) - async def async_wrapper(name, arguments): - # type: (str, Any) -> Any - span_data_key, span_name, mcp_method_name, result_data_key = ( - _get_span_config("prompt", name) - ) - with get_start_span_function()( - op=OP.MCP_SERVER, - name=span_name, - origin=MCPIntegration.origin, - ) as span: - request_id = None - try: - ctx = request_ctx.get() - request_id = ctx.request_id - except LookupError: - pass - # Include name in arguments dict for span data - args_with_name = {"name": name} - if arguments: - args_with_name.update(arguments) - _set_span_input_data( - span, - name, - span_data_key, - mcp_method_name, - args_with_name, - request_id, - ) - try: - result = await func(name, arguments) - _set_span_output_data( - span, result, result_data_key, "prompt" - ) - return result - except Exception as e: - sentry_sdk.capture_exception(e) - raise - - return original_get_prompt(self)(async_wrapper) - else: - - @wraps(func) - def sync_wrapper(name, arguments): - # type: (str, Any) -> Any - span_data_key, span_name, mcp_method_name, result_data_key = ( - _get_span_config("prompt", name) - ) - with get_start_span_function()( - op=OP.MCP_SERVER, - name=span_name, - origin=MCPIntegration.origin, - ) as span: - request_id = None - try: - ctx = request_ctx.get() - request_id = ctx.request_id - except LookupError: - pass - # Include name in arguments dict for span data - args_with_name = {"name": name} - if arguments: - args_with_name.update(arguments) - _set_span_input_data( - span, - name, - span_data_key, - mcp_method_name, - args_with_name, - request_id, - ) - try: - result = func(name, arguments) - _set_span_output_data( - span, result, result_data_key, "prompt" - ) - return result - except Exception as e: - sentry_sdk.capture_exception(e) - raise - - return original_get_prompt(self)(sync_wrapper) - - return decorator + """Patched version of Server.get_prompt that adds Sentry instrumentation.""" + return lambda func: _create_instrumented_decorator( + original_get_prompt, "prompt", self + )(func) Server.get_prompt = patched_get_prompt # type: ignore @@ -381,107 +480,9 @@ def sync_wrapper(name, arguments): def patched_read_resource(self): # type: (Server) -> Callable[[Callable[..., Any]], Callable[..., Any]] - def decorator(func): - # type: (Callable[..., Any]) -> Callable[..., Any] - if inspect.iscoroutinefunction(func): - - @wraps(func) - async def async_wrapper(uri): - # type: (Any) -> Any - uri_str = str(uri) if uri else "unknown" - # Extract protocol/scheme from URI - protocol = None - if hasattr(uri, "scheme"): - protocol = uri.scheme - elif uri_str and "://" in uri_str: - protocol = uri_str.split("://")[0] - - span_data_key, span_name, mcp_method_name, result_data_key = ( - _get_span_config("resource", uri_str) - ) - with get_start_span_function()( - op=OP.MCP_SERVER, - name=span_name, - origin=MCPIntegration.origin, - ) as span: - request_id = None - try: - ctx = request_ctx.get() - request_id = ctx.request_id - except LookupError: - pass - _set_span_input_data( - span, - uri_str, - span_data_key, - mcp_method_name, - {}, - request_id, - ) - # Set protocol if found - if protocol: - span.set_data(SPANDATA.MCP_RESOURCE_PROTOCOL, protocol) - try: - result = await func(uri) - _set_span_output_data( - span, result, result_data_key, "resource" - ) - return result - except Exception as e: - sentry_sdk.capture_exception(e) - raise - - return original_read_resource(self)(async_wrapper) - else: - - @wraps(func) - def sync_wrapper(uri): - # type: (Any) -> Any - uri_str = str(uri) if uri else "unknown" - # Extract protocol/scheme from URI - protocol = None - if hasattr(uri, "scheme"): - protocol = uri.scheme - elif uri_str and "://" in uri_str: - protocol = uri_str.split("://")[0] - - span_data_key, span_name, mcp_method_name, result_data_key = ( - _get_span_config("resource", uri_str) - ) - with get_start_span_function()( - op=OP.MCP_SERVER, - name=span_name, - origin=MCPIntegration.origin, - ) as span: - request_id = None - try: - ctx = request_ctx.get() - request_id = ctx.request_id - except LookupError: - pass - _set_span_input_data( - span, - uri_str, - span_data_key, - mcp_method_name, - {}, - request_id, - ) - # Set protocol if found - if protocol: - span.set_data(SPANDATA.MCP_RESOURCE_PROTOCOL, protocol) - try: - result = func(uri) - _set_span_output_data( - span, result, result_data_key, "resource" - ) - return result - except Exception as e: - sentry_sdk.capture_exception(e) - raise - - return original_read_resource(self)(sync_wrapper) - - return decorator + """Patched version of Server.read_resource that adds Sentry instrumentation.""" + return lambda func: _create_instrumented_decorator( + original_read_resource, "resource", self + )(func) Server.read_resource = patched_read_resource # type: ignore diff --git a/sentry_sdk/integrations/mcp/transport.py b/sentry_sdk/integrations/mcp/transport.py index 9f5e36ecd6..5a731aed6f 100644 --- a/sentry_sdk/integrations/mcp/transport.py +++ b/sentry_sdk/integrations/mcp/transport.py @@ -1,18 +1,28 @@ """ -Transport detection for MCP servers. +Transport detection and session tracking for MCP servers. -This module provides functionality to detect which transport method is being used -by an MCP server (stdio/pipe vs HTTP-based transports like SSE/WebSocket). +This module provides functionality to: +1. Detect which transport method is being used (stdio/pipe vs HTTP-based transports) +2. Track session IDs for HTTP-based transports -Detection is done lazily by inspecting the request context at runtime: +Transport detection is done lazily by inspecting the request context at runtime: - If there's an HTTP request context (SSE/WebSocket), transport is "tcp" - If there's no request context (stdio), transport is "pipe" + +Session ID tracking is done by patching the StreamableHTTPServerTransport to store +the session ID in a context variable that can be accessed during handler execution. """ +import contextvars from typing import TYPE_CHECKING if TYPE_CHECKING: from typing import Optional, Any + from starlette.types import Receive, Scope, Send + + +# Context variable to store the current MCP session ID +mcp_session_id_ctx = contextvars.ContextVar("mcp_session_id", default=None) # type: contextvars.ContextVar[Optional[str]] def detect_mcp_transport_from_context(request_ctx): @@ -46,3 +56,38 @@ def detect_mcp_transport_from_context(request_ctx): pass return None + + +def patch_streamable_http_transport(): + # type: () -> None + """ + Patches the StreamableHTTPServerTransport to store session IDs in context. + + This allows handler code to access the session ID via the mcp_session_id_ctx + context variable, regardless of whether it's the first request (where the + session ID hasn't been sent to the client yet) or a subsequent request. + """ + try: + from mcp.server.streamable_http import StreamableHTTPServerTransport + except ImportError: + # StreamableHTTP transport not available + return + + original_handle_request = StreamableHTTPServerTransport.handle_request + + async def patched_handle_request(self, scope, receive, send): + # type: (Any, Scope, Receive, Send) -> None + """Wrap handle_request to set session ID in context.""" + # Store session ID in context variable before handling request + token = None + if hasattr(self, "mcp_session_id") and self.mcp_session_id: + token = mcp_session_id_ctx.set(self.mcp_session_id) + + try: + await original_handle_request(self, scope, receive, send) + finally: + # Reset context after request + if token is not None: + mcp_session_id_ctx.reset(token) + + StreamableHTTPServerTransport.handle_request = patched_handle_request # type: ignore From e0e24e1bd65b3ef11bebb059c6a99e16fd3f0a39 Mon Sep 17 00:00:00 2001 From: Fabian Schindler Date: Mon, 20 Oct 2025 16:27:27 +0200 Subject: [PATCH 06/18] fix: retrieval of session ID during a request --- sentry_sdk/integrations/mcp/__init__.py | 6 --- sentry_sdk/integrations/mcp/lowlevel.py | 67 +++++++++++++++--------- sentry_sdk/integrations/mcp/transport.py | 65 ++++++++++------------- 3 files changed, 69 insertions(+), 69 deletions(-) diff --git a/sentry_sdk/integrations/mcp/__init__.py b/sentry_sdk/integrations/mcp/__init__.py index efd5778c76..0a4e9fa704 100644 --- a/sentry_sdk/integrations/mcp/__init__.py +++ b/sentry_sdk/integrations/mcp/__init__.py @@ -27,15 +27,9 @@ def setup_once(): Patches MCP server classes to instrument handler execution. """ from sentry_sdk.integrations.mcp.lowlevel import patch_lowlevel_server - from sentry_sdk.integrations.mcp.transport import ( - patch_streamable_http_transport, - ) # Patch server classes to instrument handlers patch_lowlevel_server() - # Patch HTTP transport to track session IDs - patch_streamable_http_transport() - __all__ = ["MCPIntegration"] diff --git a/sentry_sdk/integrations/mcp/lowlevel.py b/sentry_sdk/integrations/mcp/lowlevel.py index a5b7a3c00d..0632cfc3da 100644 --- a/sentry_sdk/integrations/mcp/lowlevel.py +++ b/sentry_sdk/integrations/mcp/lowlevel.py @@ -12,7 +12,7 @@ from sentry_sdk.integrations.mcp import MCPIntegration from sentry_sdk.integrations.mcp.transport import ( detect_mcp_transport_from_context, - mcp_session_id_ctx, + get_session_id_from_context, ) from sentry_sdk.utils import safe_serialize @@ -24,6 +24,28 @@ from typing import Any, Callable +def _get_request_context_data(): + # type: () -> tuple[str | None, str | None] + """ + Extract request ID and session ID from the MCP request context. + + Returns: + Tuple of (request_id, session_id). Either value may be None if not available. + """ + request_id = None # type: str | None + session_id = None # type: str | None + + try: + ctx = request_ctx.get() + request_id = ctx.request_id + session_id = get_session_id_from_context(ctx) + except LookupError: + # No request context available + pass + + return request_id, session_id + + def _get_span_config(handler_type, item_name): # type: (str, str) -> tuple[str, str, str, str | None] """ @@ -51,9 +73,15 @@ def _get_span_config(handler_type, item_name): def _set_span_input_data( - span, handler_name, span_data_key, mcp_method_name, arguments, request_id=None + span, + handler_name, + span_data_key, + mcp_method_name, + arguments, + request_id=None, + session_id=None, ): - # type: (Any, str, str, str, dict[str, Any], str | None) -> None + # type: (Any, str, str, str, dict[str, Any], str | None, str | None) -> None """Set input span data for MCP handlers.""" # Set handler identifier span.set_data(span_data_key, handler_name) @@ -69,19 +97,14 @@ def _set_span_input_data( # No request context available - likely stdio span.set_data(SPANDATA.MCP_TRANSPORT, "pipe") - # Extract session ID from context variable if available (HTTP transport) - try: - session_id = mcp_session_id_ctx.get() - if session_id: - span.set_data(SPANDATA.MCP_SESSION_ID, session_id) - except Exception: - # Session ID not available or transport module not imported - pass - # Set request_id if provided if request_id: span.set_data(SPANDATA.MCP_REQUEST_ID, request_id) + # Set session_id if provided + if session_id: + span.set_data(SPANDATA.MCP_SESSION_ID, session_id) + # Set request arguments (excluding common request context objects) for k, v in arguments.items(): span.set_data(f"mcp.request.argument.{k}", safe_serialize(v)) @@ -262,13 +285,8 @@ async def _async_handler_wrapper(handler_type, func, original_args): name=span_name, origin=MCPIntegration.origin, ) as span: - # Get request ID from context - request_id = None - try: - ctx = request_ctx.get() - request_id = ctx.request_id - except LookupError: - pass + # Get request ID and session ID from context + request_id, session_id = _get_request_context_data() # Set input span data _set_span_input_data( @@ -278,6 +296,7 @@ async def _async_handler_wrapper(handler_type, func, original_args): mcp_method_name, arguments, request_id, + session_id, ) # For resources, extract and set protocol @@ -329,13 +348,8 @@ def _sync_handler_wrapper(handler_type, func, original_args): name=span_name, origin=MCPIntegration.origin, ) as span: - # Get request ID from context - request_id = None - try: - ctx = request_ctx.get() - request_id = ctx.request_id - except LookupError: - pass + # Get request ID and session ID from context + request_id, session_id = _get_request_context_data() # Set input span data _set_span_input_data( @@ -345,6 +359,7 @@ def _sync_handler_wrapper(handler_type, func, original_args): mcp_method_name, arguments, request_id, + session_id, ) # For resources, extract and set protocol diff --git a/sentry_sdk/integrations/mcp/transport.py b/sentry_sdk/integrations/mcp/transport.py index 5a731aed6f..b3bfa51f1f 100644 --- a/sentry_sdk/integrations/mcp/transport.py +++ b/sentry_sdk/integrations/mcp/transport.py @@ -9,20 +9,15 @@ - If there's an HTTP request context (SSE/WebSocket), transport is "tcp" - If there's no request context (stdio), transport is "pipe" -Session ID tracking is done by patching the StreamableHTTPServerTransport to store -the session ID in a context variable that can be accessed during handler execution. +Session ID tracking is done by patching Server._run_request_handler to store a +reference to the server instance in the request context. The session ID can then +be retrieved from the server's transport when needed during handler execution. """ -import contextvars from typing import TYPE_CHECKING if TYPE_CHECKING: from typing import Optional, Any - from starlette.types import Receive, Scope, Send - - -# Context variable to store the current MCP session ID -mcp_session_id_ctx = contextvars.ContextVar("mcp_session_id", default=None) # type: contextvars.ContextVar[Optional[str]] def detect_mcp_transport_from_context(request_ctx): @@ -58,36 +53,32 @@ def detect_mcp_transport_from_context(request_ctx): return None -def patch_streamable_http_transport(): - # type: () -> None +def get_session_id_from_context(request_ctx): + # type: (Any) -> Optional[str] """ - Patches the StreamableHTTPServerTransport to store session IDs in context. + Extract session ID from the request context. + + The session ID is sent by the client in the MCP-Session-Id header and is + available in the Starlette Request object stored in ctx.request. - This allows handler code to access the session ID via the mcp_session_id_ctx - context variable, regardless of whether it's the first request (where the - session ID hasn't been sent to the client yet) or a subsequent request. + Args: + request_ctx: The MCP request context object + + Returns: + Session ID string if available, None otherwise """ try: - from mcp.server.streamable_http import StreamableHTTPServerTransport - except ImportError: - # StreamableHTTP transport not available - return - - original_handle_request = StreamableHTTPServerTransport.handle_request - - async def patched_handle_request(self, scope, receive, send): - # type: (Any, Scope, Receive, Send) -> None - """Wrap handle_request to set session ID in context.""" - # Store session ID in context variable before handling request - token = None - if hasattr(self, "mcp_session_id") and self.mcp_session_id: - token = mcp_session_id_ctx.set(self.mcp_session_id) - - try: - await original_handle_request(self, scope, receive, send) - finally: - # Reset context after request - if token is not None: - mcp_session_id_ctx.reset(token) - - StreamableHTTPServerTransport.handle_request = patched_handle_request # type: ignore + # The Starlette Request object is stored in ctx.request + if hasattr(request_ctx, "request") and request_ctx.request is not None: + request = request_ctx.request + + # Check if it's a Starlette Request with headers + if hasattr(request, "headers"): + # The session ID is sent in the mcp-session-id header + # MCP_SESSION_ID_HEADER = "mcp-session-id" + return request.headers.get("mcp-session-id") + + except Exception: + pass + + return None From 0f98c8039410bd5788676159ae97acbfc223e135 Mon Sep 17 00:00:00 2001 From: Fabian Schindler Date: Mon, 20 Oct 2025 16:51:05 +0200 Subject: [PATCH 07/18] chore: move MCP integration to a single module --- .../integrations/{mcp/lowlevel.py => mcp.py} | 111 ++++++++++++------ sentry_sdk/integrations/mcp/__init__.py | 35 ------ sentry_sdk/integrations/mcp/transport.py | 84 ------------- 3 files changed, 74 insertions(+), 156 deletions(-) rename sentry_sdk/integrations/{mcp/lowlevel.py => mcp.py} (85%) delete mode 100644 sentry_sdk/integrations/mcp/__init__.py delete mode 100644 sentry_sdk/integrations/mcp/transport.py diff --git a/sentry_sdk/integrations/mcp/lowlevel.py b/sentry_sdk/integrations/mcp.py similarity index 85% rename from sentry_sdk/integrations/mcp/lowlevel.py rename to sentry_sdk/integrations/mcp.py index 0632cfc3da..55d5f5a279 100644 --- a/sentry_sdk/integrations/mcp/lowlevel.py +++ b/sentry_sdk/integrations/mcp.py @@ -1,5 +1,10 @@ """ -Patches for mcp.server.lowlevel.Server class. +Sentry integration for MCP (Model Context Protocol) servers. + +This integration instruments MCP servers to create spans for tool, prompt, +and resource handler execution, and captures errors that occur during execution. + +Supports the low-level `mcp.server.lowlevel.Server` API. """ import inspect @@ -9,45 +14,59 @@ import sentry_sdk from sentry_sdk.ai.utils import get_start_span_function from sentry_sdk.consts import OP, SPANDATA -from sentry_sdk.integrations.mcp import MCPIntegration -from sentry_sdk.integrations.mcp.transport import ( - detect_mcp_transport_from_context, - get_session_id_from_context, -) +from sentry_sdk.integrations import Integration, DidNotEnable from sentry_sdk.utils import safe_serialize -from mcp.server.lowlevel import Server -from mcp.server.lowlevel.server import request_ctx +try: + from mcp.server.lowlevel import Server + from mcp.server.lowlevel.server import request_ctx +except ImportError: + raise DidNotEnable("MCP SDK not installed") if TYPE_CHECKING: - from typing import Any, Callable + from typing import Any, Callable, Optional def _get_request_context_data(): - # type: () -> tuple[str | None, str | None] + # type: () -> tuple[Optional[str], Optional[str], str] """ - Extract request ID and session ID from the MCP request context. + Extract request ID, session ID, and transport type from the MCP request context. Returns: - Tuple of (request_id, session_id). Either value may be None if not available. + Tuple of (request_id, session_id, transport). + - request_id: May be None if not available + - session_id: May be None if not available + - transport: "tcp" for HTTP-based, "pipe" for stdio """ - request_id = None # type: str | None - session_id = None # type: str | None + request_id = None # type: Optional[str] + session_id = None # type: Optional[str] + transport = "pipe" # type: str try: ctx = request_ctx.get() request_id = ctx.request_id - session_id = get_session_id_from_context(ctx) + + # Check if there's an HTTP request (SSE/WebSocket) or stdio + if hasattr(ctx, "request") and ctx.request is not None: + # This is HTTP-based (SSE or WebSocket) + transport = "tcp" + request = ctx.request + + # Extract session ID from HTTP headers + if hasattr(request, "headers"): + session_id = request.headers.get("mcp-session-id") + # If ctx.request exists but is None, it's stdio (transport already set to "pipe") + except LookupError: - # No request context available + # No request context available - default to pipe pass - return request_id, session_id + return request_id, session_id, transport def _get_span_config(handler_type, item_name): - # type: (str, str) -> tuple[str, str, str, str | None] + # type: (str, str) -> tuple[str, str, str, Optional[str]] """ Get span configuration based on handler type. @@ -78,24 +97,18 @@ def _set_span_input_data( span_data_key, mcp_method_name, arguments, - request_id=None, - session_id=None, + request_id, + session_id, + transport, ): - # type: (Any, str, str, str, dict[str, Any], str | None, str | None) -> None + # type: (Any, str, str, str, dict[str, Any], Optional[str], Optional[str], str) -> None """Set input span data for MCP handlers.""" # Set handler identifier span.set_data(span_data_key, handler_name) span.set_data(SPANDATA.MCP_METHOD_NAME, mcp_method_name) - # Detect and set transport if available - try: - ctx = request_ctx.get() - transport = detect_mcp_transport_from_context(ctx) - if transport is not None: - span.set_data(SPANDATA.MCP_TRANSPORT, transport) - except LookupError: - # No request context available - likely stdio - span.set_data(SPANDATA.MCP_TRANSPORT, "pipe") + # Set transport type + span.set_data(SPANDATA.MCP_TRANSPORT, transport) # Set request_id if provided if request_id: @@ -152,7 +165,7 @@ def _extract_tool_result_content(result): def _set_span_output_data(span, result, result_data_key, handler_type): - # type: (Any, Any, str | None, str) -> None + # type: (Any, Any, Optional[str], str) -> None """Set output span data for MCP handlers.""" if result is None: return @@ -223,8 +236,11 @@ def _set_span_output_data(span, result, result_data_key, handler_type): # Resources don't capture result content (result_data_key is None) +# Handler data preparation and wrapping + + def _prepare_handler_data(handler_type, original_args): - # type: (str, tuple[Any, ...]) -> tuple[str, dict[str, Any], str, str, str, str | None] + # type: (str, tuple[Any, ...]) -> tuple[str, dict[str, Any], str, str, str, Optional[str]] """ Prepare common handler data for both async and sync wrappers. @@ -285,8 +301,8 @@ async def _async_handler_wrapper(handler_type, func, original_args): name=span_name, origin=MCPIntegration.origin, ) as span: - # Get request ID and session ID from context - request_id, session_id = _get_request_context_data() + # Get request ID, session ID, and transport from context + request_id, session_id, transport = _get_request_context_data() # Set input span data _set_span_input_data( @@ -297,6 +313,7 @@ async def _async_handler_wrapper(handler_type, func, original_args): arguments, request_id, session_id, + transport, ) # For resources, extract and set protocol @@ -348,8 +365,8 @@ def _sync_handler_wrapper(handler_type, func, original_args): name=span_name, origin=MCPIntegration.origin, ) as span: - # Get request ID and session ID from context - request_id, session_id = _get_request_context_data() + # Get request ID, session ID, and transport from context + request_id, session_id, transport = _get_request_context_data() # Set input span data _set_span_input_data( @@ -360,6 +377,7 @@ def _sync_handler_wrapper(handler_type, func, original_args): arguments, request_id, session_id, + transport, ) # For resources, extract and set protocol @@ -461,7 +479,7 @@ def instrumented_decorator(func): return instrumented_decorator -def patch_lowlevel_server(): +def _patch_lowlevel_server(): # type: () -> None """ Patches the mcp.server.lowlevel.Server class to instrument handler execution. @@ -501,3 +519,22 @@ def patched_read_resource(self): )(func) Server.read_resource = patched_read_resource # type: ignore + + +# Integration class + + +class MCPIntegration(Integration): + identifier = "mcp" + origin = "auto.ai.mcp" + + @staticmethod + def setup_once(): + # type: () -> None + """ + Patches MCP server classes to instrument handler execution. + """ + _patch_lowlevel_server() + + +__all__ = ["MCPIntegration"] diff --git a/sentry_sdk/integrations/mcp/__init__.py b/sentry_sdk/integrations/mcp/__init__.py deleted file mode 100644 index 0a4e9fa704..0000000000 --- a/sentry_sdk/integrations/mcp/__init__.py +++ /dev/null @@ -1,35 +0,0 @@ -""" -Sentry integration for MCP (Model Context Protocol) servers. - -This integration instruments MCP servers to create spans for tool, prompt, -and resource handler execution, and captures errors that occur during execution. - -Supports both the low-level `mcp.server.lowlevel.Server` and high-level -`mcp.server.fastmcp.FastMCP` APIs. -""" - -from sentry_sdk.integrations import Integration, DidNotEnable - -try: - import mcp.server.lowlevel # noqa: F401 -except ImportError: - raise DidNotEnable("MCP SDK not installed") - - -class MCPIntegration(Integration): - identifier = "mcp" - origin = "auto.ai.mcp" - - @staticmethod - def setup_once(): - # type: () -> None - """ - Patches MCP server classes to instrument handler execution. - """ - from sentry_sdk.integrations.mcp.lowlevel import patch_lowlevel_server - - # Patch server classes to instrument handlers - patch_lowlevel_server() - - -__all__ = ["MCPIntegration"] diff --git a/sentry_sdk/integrations/mcp/transport.py b/sentry_sdk/integrations/mcp/transport.py deleted file mode 100644 index b3bfa51f1f..0000000000 --- a/sentry_sdk/integrations/mcp/transport.py +++ /dev/null @@ -1,84 +0,0 @@ -""" -Transport detection and session tracking for MCP servers. - -This module provides functionality to: -1. Detect which transport method is being used (stdio/pipe vs HTTP-based transports) -2. Track session IDs for HTTP-based transports - -Transport detection is done lazily by inspecting the request context at runtime: -- If there's an HTTP request context (SSE/WebSocket), transport is "tcp" -- If there's no request context (stdio), transport is "pipe" - -Session ID tracking is done by patching Server._run_request_handler to store a -reference to the server instance in the request context. The session ID can then -be retrieved from the server's transport when needed during handler execution. -""" - -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from typing import Optional, Any - - -def detect_mcp_transport_from_context(request_ctx): - # type: (Any) -> Optional[str] - """ - Detect MCP transport type from the request context. - - Args: - request_ctx: The MCP request context object (from request_ctx.get()) - - Returns: - "pipe" for stdio transport, "tcp" for HTTP-based transports (SSE/WebSocket), - or None if transport type cannot be determined. - - Detection logic: - - If request_ctx has a 'request' attribute with a value, it's HTTP-based → "tcp" - - If request_ctx exists but has no 'request' or request is None, it's stdio → "pipe" - - If we can't determine, return None - """ - try: - # Check if there's a request object in the context - # SSE and WebSocket transports set this via ServerMessageMetadata - if hasattr(request_ctx, "request") and request_ctx.request is not None: - # This is SSE or WebSocket (HTTP-based) - return "tcp" - elif hasattr(request_ctx, "request"): - # Context exists but no HTTP request - this is stdio - return "pipe" - except Exception: - # If anything goes wrong, return None - pass - - return None - - -def get_session_id_from_context(request_ctx): - # type: (Any) -> Optional[str] - """ - Extract session ID from the request context. - - The session ID is sent by the client in the MCP-Session-Id header and is - available in the Starlette Request object stored in ctx.request. - - Args: - request_ctx: The MCP request context object - - Returns: - Session ID string if available, None otherwise - """ - try: - # The Starlette Request object is stored in ctx.request - if hasattr(request_ctx, "request") and request_ctx.request is not None: - request = request_ctx.request - - # Check if it's a Starlette Request with headers - if hasattr(request, "headers"): - # The session ID is sent in the mcp-session-id header - # MCP_SESSION_ID_HEADER = "mcp-session-id" - return request.headers.get("mcp-session-id") - - except Exception: - pass - - return None From 51e004611d1db3f95215f2b614e442286c6a9430 Mon Sep 17 00:00:00 2001 From: Fabian Schindler Date: Tue, 21 Oct 2025 10:43:51 +0200 Subject: [PATCH 08/18] fix: do not automatically enable MCP integration --- sentry_sdk/integrations/__init__.py | 1 - 1 file changed, 1 deletion(-) diff --git a/sentry_sdk/integrations/__init__.py b/sentry_sdk/integrations/__init__.py index 8db2aa5d30..9e279b8345 100644 --- a/sentry_sdk/integrations/__init__.py +++ b/sentry_sdk/integrations/__init__.py @@ -98,7 +98,6 @@ def iter_default_integrations(with_auto_enabling_integrations): "sentry_sdk.integrations.langgraph.LanggraphIntegration", "sentry_sdk.integrations.litestar.LitestarIntegration", "sentry_sdk.integrations.loguru.LoguruIntegration", - "sentry_sdk.integrations.mcp.MCPIntegration", "sentry_sdk.integrations.openai.OpenAIIntegration", "sentry_sdk.integrations.pymongo.PyMongoIntegration", "sentry_sdk.integrations.pyramid.PyramidIntegration", From b729c293a8ec30f14571ef02bf7bc61ba9d82441 Mon Sep 17 00:00:00 2001 From: Fabian Schindler Date: Tue, 21 Oct 2025 11:06:25 +0200 Subject: [PATCH 09/18] test: add tests for MCP integration --- sentry_sdk/integrations/mcp.py | 20 +- tests/integrations/mcp/__init__.py | 0 tests/integrations/mcp/conftest.py | 25 + tests/integrations/mcp/test_mcp.py | 807 +++++++++++++++++++++++++++++ 4 files changed, 840 insertions(+), 12 deletions(-) create mode 100644 tests/integrations/mcp/__init__.py create mode 100644 tests/integrations/mcp/conftest.py create mode 100644 tests/integrations/mcp/test_mcp.py diff --git a/sentry_sdk/integrations/mcp.py b/sentry_sdk/integrations/mcp.py index 55d5f5a279..85303407cd 100644 --- a/sentry_sdk/integrations/mcp.py +++ b/sentry_sdk/integrations/mcp.py @@ -45,18 +45,14 @@ def _get_request_context_data(): try: ctx = request_ctx.get() - request_id = ctx.request_id - - # Check if there's an HTTP request (SSE/WebSocket) or stdio - if hasattr(ctx, "request") and ctx.request is not None: - # This is HTTP-based (SSE or WebSocket) - transport = "tcp" - request = ctx.request - - # Extract session ID from HTTP headers - if hasattr(request, "headers"): - session_id = request.headers.get("mcp-session-id") - # If ctx.request exists but is None, it's stdio (transport already set to "pipe") + + if ctx is not None: + request_id = ctx.request_id + if hasattr(ctx, "request") and ctx.request is not None: + transport = "tcp" + request = ctx.request + if hasattr(request, "headers"): + session_id = request.headers.get("mcp-session-id") except LookupError: # No request context available - default to pipe diff --git a/tests/integrations/mcp/__init__.py b/tests/integrations/mcp/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/integrations/mcp/conftest.py b/tests/integrations/mcp/conftest.py new file mode 100644 index 0000000000..8458617066 --- /dev/null +++ b/tests/integrations/mcp/conftest.py @@ -0,0 +1,25 @@ +import pytest + +try: + from mcp.server.lowlevel.server import request_ctx +except ImportError: + request_ctx = None + + +@pytest.fixture(autouse=True) +def reset_request_ctx(): + """Reset request context before and after each test""" + if request_ctx is not None: + try: + if request_ctx.get() is not None: + request_ctx.set(None) + except LookupError: + pass + + yield + + if request_ctx is not None: + try: + request_ctx.set(None) + except LookupError: + pass diff --git a/tests/integrations/mcp/test_mcp.py b/tests/integrations/mcp/test_mcp.py new file mode 100644 index 0000000000..684dec2c05 --- /dev/null +++ b/tests/integrations/mcp/test_mcp.py @@ -0,0 +1,807 @@ +""" +Unit tests for the MCP (Model Context Protocol) integration. + +This test suite covers: +- Tool handlers (sync and async) +- Prompt handlers (sync and async) +- Resource handlers (sync and async) +- Error handling for each handler type +- Request context data extraction (request_id, session_id, transport) +- Tool result content extraction (various formats) +- Span data validation +- Origin tracking + +The tests mock the MCP server components and request context to verify +that the integration properly instruments MCP handlers with Sentry spans. +""" + +import pytest +import json +from unittest import mock + +try: + from unittest.mock import AsyncMock +except ImportError: + + class AsyncMock(mock.MagicMock): + async def __call__(self, *args, **kwargs): + return super(AsyncMock, self).__call__(*args, **kwargs) + + +from sentry_sdk import start_transaction +from sentry_sdk.consts import SPANDATA, OP +from sentry_sdk.integrations.mcp import MCPIntegration + +try: + from mcp.server.lowlevel import Server + from mcp.server.lowlevel.server import request_ctx +except ImportError: + pytest.skip("MCP not installed", allow_module_level=True) + + +# Mock MCP types and structures +class MockURI: + """Mock URI object for resource testing""" + + def __init__(self, uri_string): + self.scheme = uri_string.split("://")[0] if "://" in uri_string else "" + self.path = uri_string.split("://")[1] if "://" in uri_string else uri_string + self._uri_string = uri_string + + def __str__(self): + return self._uri_string + + +class MockRequestContext: + """Mock MCP request context""" + + def __init__(self, request_id=None, session_id=None, transport="pipe"): + self.request_id = request_id + if transport == "tcp": + self.request = MockHTTPRequest(session_id) + else: + self.request = None + + +class MockHTTPRequest: + """Mock HTTP request for SSE/WebSocket transport""" + + def __init__(self, session_id=None): + self.headers = {} + if session_id: + self.headers["mcp-session-id"] = session_id + + +class MockTextContent: + """Mock TextContent object""" + + def __init__(self, text): + self.text = text + + +class MockPromptMessage: + """Mock PromptMessage object""" + + def __init__(self, role, content_text): + self.role = role + self.content = MockTextContent(content_text) + + +class MockGetPromptResult: + """Mock GetPromptResult object""" + + def __init__(self, messages): + self.messages = messages + + +def test_integration_patches_server(sentry_init): + """Test that MCPIntegration patches the Server class""" + # Get original methods before integration + original_call_tool = Server.call_tool + original_get_prompt = Server.get_prompt + original_read_resource = Server.read_resource + + sentry_init( + integrations=[MCPIntegration()], + traces_sample_rate=1.0, + ) + + # After initialization, the methods should be patched + assert Server.call_tool is not original_call_tool + assert Server.get_prompt is not original_get_prompt + assert Server.read_resource is not original_read_resource + + +def test_tool_handler_sync(sentry_init, capture_events): + """Test that synchronous tool handlers create proper spans""" + sentry_init( + integrations=[MCPIntegration()], + traces_sample_rate=1.0, + ) + events = capture_events() + + server = Server("test-server") + + # Set up mock request context + mock_ctx = MockRequestContext(request_id="req-123", transport="pipe") + request_ctx.set(mock_ctx) + + @server.call_tool() + def test_tool(tool_name, arguments): + return {"result": "success", "value": 42} + + with start_transaction(name="mcp tx"): + # Call the tool handler + result = test_tool("calculate", {"x": 10, "y": 5}) + + assert result == {"result": "success", "value": 42} + + (tx,) = events + assert tx["type"] == "transaction" + assert len(tx["spans"]) == 1 + + span = tx["spans"][0] + assert span["op"] == OP.MCP_SERVER + assert span["description"] == "tools/call calculate" + assert span["origin"] == "auto.ai.mcp" + + # Check span data + assert span["data"][SPANDATA.MCP_TOOL_NAME] == "calculate" + assert span["data"][SPANDATA.MCP_METHOD_NAME] == "tools/call" + assert span["data"][SPANDATA.MCP_TRANSPORT] == "pipe" + assert span["data"][SPANDATA.MCP_REQUEST_ID] == "req-123" + assert span["data"]["mcp.request.argument.x"] == "10" + assert span["data"]["mcp.request.argument.y"] == "5" + assert span["data"][SPANDATA.MCP_TOOL_RESULT_CONTENT] == json.dumps( + { + "result": "success", + "value": 42, + } + ) + assert span["data"][SPANDATA.MCP_TOOL_RESULT_CONTENT_COUNT] == 2 + + +@pytest.mark.asyncio +async def test_tool_handler_async(sentry_init, capture_events): + """Test that async tool handlers create proper spans""" + sentry_init( + integrations=[MCPIntegration()], + traces_sample_rate=1.0, + ) + events = capture_events() + + server = Server("test-server") + + # Set up mock request context + mock_ctx = MockRequestContext( + request_id="req-456", session_id="session-789", transport="tcp" + ) + request_ctx.set(mock_ctx) + + @server.call_tool() + async def test_tool_async(tool_name, arguments): + return {"status": "completed"} + + with start_transaction(name="mcp tx"): + result = await test_tool_async("process", {"data": "test"}) + + assert result == {"status": "completed"} + + (tx,) = events + assert tx["type"] == "transaction" + assert len(tx["spans"]) == 1 + + span = tx["spans"][0] + assert span["op"] == OP.MCP_SERVER + assert span["description"] == "tools/call process" + assert span["origin"] == "auto.ai.mcp" + + # Check span data + assert span["data"][SPANDATA.MCP_TOOL_NAME] == "process" + assert span["data"][SPANDATA.MCP_METHOD_NAME] == "tools/call" + assert span["data"][SPANDATA.MCP_TRANSPORT] == "tcp" + assert span["data"][SPANDATA.MCP_REQUEST_ID] == "req-456" + assert span["data"][SPANDATA.MCP_SESSION_ID] == "session-789" + assert span["data"]["mcp.request.argument.data"] == '"test"' + assert span["data"][SPANDATA.MCP_TOOL_RESULT_CONTENT] == json.dumps( + {"status": "completed"} + ) + + +def test_tool_handler_with_error(sentry_init, capture_events): + """Test that tool handler errors are captured properly""" + sentry_init( + integrations=[MCPIntegration()], + traces_sample_rate=1.0, + ) + events = capture_events() + + server = Server("test-server") + + # Set up mock request context + mock_ctx = MockRequestContext(request_id="req-error", transport="pipe") + request_ctx.set(mock_ctx) + + @server.call_tool() + def failing_tool(tool_name, arguments): + raise ValueError("Tool execution failed") + + with start_transaction(name="mcp tx"): + with pytest.raises(ValueError): + failing_tool("bad_tool", {}) + + # Should have error event and transaction + assert len(events) == 2 + error_event, tx = events + + # Check error event + assert error_event["level"] == "error" + assert error_event["exception"]["values"][0]["type"] == "ValueError" + assert error_event["exception"]["values"][0]["value"] == "Tool execution failed" + + # Check transaction and span + assert tx["type"] == "transaction" + assert len(tx["spans"]) == 1 + span = tx["spans"][0] + + # Error flag should be set for tools + assert span["data"][SPANDATA.MCP_TOOL_RESULT_IS_ERROR] is True + assert span["tags"]["status"] == "internal_error" + + +def test_prompt_handler_sync(sentry_init, capture_events): + """Test that synchronous prompt handlers create proper spans""" + sentry_init( + integrations=[MCPIntegration()], + traces_sample_rate=1.0, + ) + events = capture_events() + + server = Server("test-server") + + # Set up mock request context + mock_ctx = MockRequestContext(request_id="req-prompt", transport="pipe") + request_ctx.set(mock_ctx) + + @server.get_prompt() + def test_prompt(name, arguments): + return MockGetPromptResult([MockPromptMessage("user", "Tell me about Python")]) + + with start_transaction(name="mcp tx"): + result = test_prompt("code_help", {"language": "python"}) + + assert result.messages[0].role == "user" + assert result.messages[0].content.text == "Tell me about Python" + + (tx,) = events + assert tx["type"] == "transaction" + assert len(tx["spans"]) == 1 + + span = tx["spans"][0] + assert span["op"] == OP.MCP_SERVER + assert span["description"] == "prompts/get code_help" + assert span["origin"] == "auto.ai.mcp" + + # Check span data + assert span["data"][SPANDATA.MCP_PROMPT_NAME] == "code_help" + assert span["data"][SPANDATA.MCP_METHOD_NAME] == "prompts/get" + assert span["data"][SPANDATA.MCP_TRANSPORT] == "pipe" + assert span["data"][SPANDATA.MCP_REQUEST_ID] == "req-prompt" + assert span["data"]["mcp.request.argument.name"] == '"code_help"' + assert span["data"]["mcp.request.argument.language"] == '"python"' + # For single message prompts, role and content should be captured + assert span["data"][SPANDATA.MCP_PROMPT_RESULT_MESSAGE_COUNT] == 1 + assert span["data"][SPANDATA.MCP_PROMPT_RESULT_MESSAGE_ROLE] == "user" + assert ( + span["data"][SPANDATA.MCP_PROMPT_RESULT_MESSAGE_CONTENT] + == "Tell me about Python" + ) + + +@pytest.mark.asyncio +async def test_prompt_handler_async(sentry_init, capture_events): + """Test that async prompt handlers create proper spans""" + sentry_init( + integrations=[MCPIntegration()], + traces_sample_rate=1.0, + ) + events = capture_events() + + server = Server("test-server") + + # Set up mock request context + mock_ctx = MockRequestContext( + request_id="req-async-prompt", session_id="session-abc", transport="tcp" + ) + 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?"), + ] + ) + + with start_transaction(name="mcp tx"): + result = await test_prompt_async("mcp_info", {}) + + assert len(result.messages) == 2 + + (tx,) = events + assert tx["type"] == "transaction" + assert len(tx["spans"]) == 1 + + span = tx["spans"][0] + assert span["op"] == OP.MCP_SERVER + assert span["description"] == "prompts/get mcp_info" + + # For multi-message prompts, only count should be captured, not role/content + assert span["data"][SPANDATA.MCP_PROMPT_RESULT_MESSAGE_COUNT] == 2 + assert SPANDATA.MCP_PROMPT_RESULT_MESSAGE_ROLE not in span["data"] + assert SPANDATA.MCP_PROMPT_RESULT_MESSAGE_CONTENT not in span["data"] + + +def test_prompt_handler_with_error(sentry_init, capture_events): + """Test that prompt handler errors are captured""" + sentry_init( + integrations=[MCPIntegration()], + traces_sample_rate=1.0, + ) + events = capture_events() + + server = Server("test-server") + + # Set up mock request context + mock_ctx = MockRequestContext(request_id="req-error-prompt", transport="pipe") + request_ctx.set(mock_ctx) + + @server.get_prompt() + def failing_prompt(name, arguments): + raise RuntimeError("Prompt not found") + + with start_transaction(name="mcp tx"): + with pytest.raises(RuntimeError): + failing_prompt("missing_prompt", {}) + + # Should have error event and transaction + assert len(events) == 2 + error_event, tx = events + + assert error_event["level"] == "error" + assert error_event["exception"]["values"][0]["type"] == "RuntimeError" + + +def test_resource_handler_sync(sentry_init, capture_events): + """Test that synchronous resource handlers create proper spans""" + sentry_init( + integrations=[MCPIntegration()], + traces_sample_rate=1.0, + ) + events = capture_events() + + server = Server("test-server") + + # Set up mock request context + mock_ctx = MockRequestContext(request_id="req-resource", transport="pipe") + request_ctx.set(mock_ctx) + + @server.read_resource() + def test_resource(uri): + return {"content": "file contents", "mime_type": "text/plain"} + + with start_transaction(name="mcp tx"): + uri = MockURI("file:///path/to/file.txt") + result = test_resource(uri) + + assert result["content"] == "file contents" + + (tx,) = events + assert tx["type"] == "transaction" + assert len(tx["spans"]) == 1 + + span = tx["spans"][0] + assert span["op"] == OP.MCP_SERVER + assert span["description"] == "resources/read file:///path/to/file.txt" + assert span["origin"] == "auto.ai.mcp" + + # Check span data + assert span["data"][SPANDATA.MCP_RESOURCE_URI] == "file:///path/to/file.txt" + assert span["data"][SPANDATA.MCP_METHOD_NAME] == "resources/read" + assert span["data"][SPANDATA.MCP_TRANSPORT] == "pipe" + assert span["data"][SPANDATA.MCP_REQUEST_ID] == "req-resource" + assert span["data"][SPANDATA.MCP_RESOURCE_PROTOCOL] == "file" + # Resources don't capture result content + assert SPANDATA.MCP_TOOL_RESULT_CONTENT not in span["data"] + + +@pytest.mark.asyncio +async def test_resource_handler_async(sentry_init, capture_events): + """Test that async resource handlers create proper spans""" + sentry_init( + integrations=[MCPIntegration()], + traces_sample_rate=1.0, + ) + events = capture_events() + + server = Server("test-server") + + # Set up mock request context + mock_ctx = MockRequestContext( + request_id="req-async-resource", session_id="session-res", transport="tcp" + ) + request_ctx.set(mock_ctx) + + @server.read_resource() + async def test_resource_async(uri): + return {"data": "resource data"} + + with start_transaction(name="mcp tx"): + uri = MockURI("https://example.com/resource") + result = await test_resource_async(uri) + + assert result["data"] == "resource data" + + (tx,) = events + assert tx["type"] == "transaction" + assert len(tx["spans"]) == 1 + + span = tx["spans"][0] + assert span["op"] == OP.MCP_SERVER + assert span["description"] == "resources/read https://example.com/resource" + + 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" + + +def test_resource_handler_with_error(sentry_init, capture_events): + """Test that resource handler errors are captured""" + sentry_init( + integrations=[MCPIntegration()], + traces_sample_rate=1.0, + ) + events = capture_events() + + server = Server("test-server") + + # Set up mock request context + mock_ctx = MockRequestContext(request_id="req-error-resource", transport="pipe") + request_ctx.set(mock_ctx) + + @server.read_resource() + def failing_resource(uri): + raise FileNotFoundError("Resource not found") + + with start_transaction(name="mcp tx"): + with pytest.raises(FileNotFoundError): + uri = MockURI("file:///missing.txt") + failing_resource(uri) + + # Should have error event and transaction + assert len(events) == 2 + error_event, tx = events + + assert error_event["level"] == "error" + assert error_event["exception"]["values"][0]["type"] == "FileNotFoundError" + + +def test_tool_result_extraction_tuple(sentry_init, capture_events): + """Test extraction of tool results from tuple format (UnstructuredContent, StructuredContent)""" + sentry_init( + integrations=[MCPIntegration()], + traces_sample_rate=1.0, + ) + events = capture_events() + + server = Server("test-server") + + # Set up mock request context + mock_ctx = MockRequestContext(request_id="req-tuple", transport="pipe") + request_ctx.set(mock_ctx) + + @server.call_tool() + def test_tool_tuple(tool_name, arguments): + # Return CombinationContent: (UnstructuredContent, StructuredContent) + unstructured = [MockTextContent("Result text")] + structured = {"key": "value", "count": 5} + return (unstructured, structured) + + with start_transaction(name="mcp tx"): + test_tool_tuple("combo_tool", {}) + + (tx,) = events + span = tx["spans"][0] + + # Should extract the structured content (second element of tuple) + assert span["data"][SPANDATA.MCP_TOOL_RESULT_CONTENT] == json.dumps( + { + "key": "value", + "count": 5, + } + ) + assert span["data"][SPANDATA.MCP_TOOL_RESULT_CONTENT_COUNT] == 2 + + +def test_tool_result_extraction_unstructured(sentry_init, capture_events): + """Test extraction of tool results from UnstructuredContent (list of content blocks)""" + sentry_init( + integrations=[MCPIntegration()], + traces_sample_rate=1.0, + ) + events = capture_events() + + server = Server("test-server") + + # Set up mock request context + mock_ctx = MockRequestContext(request_id="req-unstructured", transport="pipe") + request_ctx.set(mock_ctx) + + @server.call_tool() + def test_tool_unstructured(tool_name, arguments): + # Return UnstructuredContent as list of content blocks + return [ + MockTextContent("First part"), + MockTextContent("Second part"), + ] + + with start_transaction(name="mcp tx"): + test_tool_unstructured("text_tool", {}) + + (tx,) = events + span = tx["spans"][0] + + # Should extract and join text from content blocks + assert span["data"][SPANDATA.MCP_TOOL_RESULT_CONTENT] == '"First part Second part"' + + +def test_request_context_no_context(sentry_init, capture_events): + """Test handling when no request context is available""" + sentry_init( + integrations=[MCPIntegration()], + traces_sample_rate=1.0, + ) + events = capture_events() + + server = Server("test-server") + + # Clear request context (simulating no context available) + # This will cause a LookupError when trying to get context + request_ctx.set(None) + + @server.call_tool() + def test_tool_no_ctx(tool_name, arguments): + return {"result": "ok"} + + with start_transaction(name="mcp tx"): + # This should work even without request context + try: + test_tool_no_ctx("tool", {}) + except LookupError: + # If it raises LookupError, that's expected when context is truly missing + pass + + # Should still create span even if context is missing + (tx,) = events + span = tx["spans"][0] + + # Transport defaults to "pipe" when no context + assert span["data"][SPANDATA.MCP_TRANSPORT] == "pipe" + # Request ID and Session ID should not be present + assert SPANDATA.MCP_REQUEST_ID not in span["data"] + assert SPANDATA.MCP_SESSION_ID not in span["data"] + + +def test_span_origin(sentry_init, capture_events): + """Test that span origin is set correctly""" + sentry_init( + integrations=[MCPIntegration()], + traces_sample_rate=1.0, + ) + events = capture_events() + + server = Server("test-server") + + # Set up mock request context + mock_ctx = MockRequestContext(request_id="req-origin", transport="pipe") + request_ctx.set(mock_ctx) + + @server.call_tool() + def test_tool(tool_name, arguments): + return {"result": "test"} + + with start_transaction(name="mcp tx"): + test_tool("origin_test", {}) + + (tx,) = events + + assert tx["contexts"]["trace"]["origin"] == "manual" + assert tx["spans"][0]["origin"] == "auto.ai.mcp" + + +def test_multiple_handlers(sentry_init, capture_events): + """Test that multiple handler calls create multiple spans""" + sentry_init( + integrations=[MCPIntegration()], + traces_sample_rate=1.0, + ) + events = capture_events() + + server = Server("test-server") + + # Set up mock request context + mock_ctx = MockRequestContext(request_id="req-multi", transport="pipe") + request_ctx.set(mock_ctx) + + @server.call_tool() + def tool1(tool_name, arguments): + return {"result": "tool1"} + + @server.call_tool() + def tool2(tool_name, arguments): + return {"result": "tool2"} + + @server.get_prompt() + def prompt1(name, arguments): + return MockGetPromptResult([MockPromptMessage("user", "Test prompt")]) + + with start_transaction(name="mcp tx"): + tool1("tool_a", {}) + tool2("tool_b", {}) + prompt1("prompt_a", {}) + + (tx,) = events + assert tx["type"] == "transaction" + assert len(tx["spans"]) == 3 + + # Check that we have different span types + span_ops = [span["op"] for span in tx["spans"]] + assert all(op == OP.MCP_SERVER for op in span_ops) + + span_descriptions = [span["description"] for span in tx["spans"]] + assert "tools/call tool_a" in span_descriptions + assert "tools/call tool_b" in span_descriptions + assert "prompts/get prompt_a" in span_descriptions + + +def test_prompt_with_dict_result(sentry_init, capture_events): + """Test prompt handler with dict result instead of GetPromptResult object""" + sentry_init( + integrations=[MCPIntegration()], + traces_sample_rate=1.0, + ) + events = capture_events() + + server = Server("test-server") + + # Set up mock request context + mock_ctx = MockRequestContext(request_id="req-dict-prompt", transport="pipe") + request_ctx.set(mock_ctx) + + @server.get_prompt() + def test_prompt_dict(name, arguments): + # Return dict format instead of GetPromptResult object + return { + "messages": [ + {"role": "user", "content": {"text": "Hello from dict"}}, + ] + } + + with start_transaction(name="mcp tx"): + test_prompt_dict("dict_prompt", {}) + + (tx,) = events + span = tx["spans"][0] + + # Should still extract message info from dict format + assert span["data"][SPANDATA.MCP_PROMPT_RESULT_MESSAGE_COUNT] == 1 + assert span["data"][SPANDATA.MCP_PROMPT_RESULT_MESSAGE_ROLE] == "user" + assert span["data"][SPANDATA.MCP_PROMPT_RESULT_MESSAGE_CONTENT] == "Hello from dict" + + +def test_resource_without_protocol(sentry_init, capture_events): + """Test resource handler with URI without protocol scheme""" + sentry_init( + integrations=[MCPIntegration()], + traces_sample_rate=1.0, + ) + events = capture_events() + + server = Server("test-server") + + # Set up mock request context + mock_ctx = MockRequestContext(request_id="req-no-proto", transport="pipe") + request_ctx.set(mock_ctx) + + @server.read_resource() + def test_resource(uri): + return {"data": "test"} + + with start_transaction(name="mcp tx"): + # URI without protocol + test_resource("simple-path") + + (tx,) = events + span = tx["spans"][0] + + assert span["data"][SPANDATA.MCP_RESOURCE_URI] == "simple-path" + # No protocol should be set + assert SPANDATA.MCP_RESOURCE_PROTOCOL not in span["data"] + + +def test_tool_with_complex_arguments(sentry_init, capture_events): + """Test tool handler with complex nested arguments""" + sentry_init( + integrations=[MCPIntegration()], + traces_sample_rate=1.0, + ) + events = capture_events() + + server = Server("test-server") + + # Set up mock request context + mock_ctx = MockRequestContext(request_id="req-complex", transport="pipe") + request_ctx.set(mock_ctx) + + @server.call_tool() + def test_tool_complex(tool_name, arguments): + return {"processed": True} + + with start_transaction(name="mcp tx"): + complex_args = { + "nested": {"key": "value", "list": [1, 2, 3]}, + "string": "test", + "number": 42, + } + test_tool_complex("complex_tool", complex_args) + + (tx,) = events + span = tx["spans"][0] + + # Complex arguments should be serialized + assert span["data"]["mcp.request.argument.nested"] == json.dumps( + {"key": "value", "list": [1, 2, 3]} + ) + assert span["data"]["mcp.request.argument.string"] == '"test"' + assert span["data"]["mcp.request.argument.number"] == "42" + assert span["data"][SPANDATA.MCP_TOOL_RESULT_CONTENT] == json.dumps( + {"processed": True} + ) + + +@pytest.mark.asyncio +async def test_async_handlers_mixed(sentry_init, capture_events): + """Test mixing sync and async handlers in the same transaction""" + sentry_init( + integrations=[MCPIntegration()], + traces_sample_rate=1.0, + ) + events = capture_events() + + server = Server("test-server") + + # Set up mock request context + mock_ctx = MockRequestContext(request_id="req-mixed", transport="pipe") + request_ctx.set(mock_ctx) + + @server.call_tool() + def sync_tool(tool_name, arguments): + return {"type": "sync"} + + @server.call_tool() + async def async_tool(tool_name, arguments): + return {"type": "async"} + + with start_transaction(name="mcp tx"): + sync_result = sync_tool("sync", {}) + async_result = await async_tool("async", {}) + + assert sync_result["type"] == "sync" + assert async_result["type"] == "async" + + (tx,) = events + assert len(tx["spans"]) == 2 + + # Both should be instrumented correctly + assert all(span["op"] == OP.MCP_SERVER for span in tx["spans"]) From 535f2afada0a2688f1c6f3676f14faa423233e8f Mon Sep 17 00:00:00 2001 From: Fabian Schindler Date: Tue, 21 Oct 2025 11:07:11 +0200 Subject: [PATCH 10/18] fix: add min version of mcp extra dependency --- sentry_sdk/integrations/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/sentry_sdk/integrations/__init__.py b/sentry_sdk/integrations/__init__.py index 9e279b8345..b4dd6fc96b 100644 --- a/sentry_sdk/integrations/__init__.py +++ b/sentry_sdk/integrations/__init__.py @@ -149,6 +149,7 @@ def iter_default_integrations(with_auto_enabling_integrations): "launchdarkly": (9, 8, 0), "litellm": (1, 77, 5), "loguru": (0, 7, 0), + "mcp": (1, 15, 0), "openai": (1, 0, 0), "openai_agents": (0, 0, 19), "openfeature": (0, 7, 1), From f12fcd35783866780752373d0d5008db878d88aa Mon Sep 17 00:00:00 2001 From: Fabian Schindler Date: Tue, 21 Oct 2025 11:08:27 +0200 Subject: [PATCH 11/18] fix: add extra dependency --- setup.py | 1 + 1 file changed, 1 insertion(+) diff --git a/setup.py b/setup.py index e0894ae9e8..3e1682e07f 100644 --- a/setup.py +++ b/setup.py @@ -68,6 +68,7 @@ def get_file_text(file_name): "litellm": ["litellm>=1.77.5"], "litestar": ["litestar>=2.0.0"], "loguru": ["loguru>=0.5"], + "mcp": ["mcp>=1.15.0"], "openai": ["openai>=1.0.0", "tiktoken>=0.3.0"], "openfeature": ["openfeature-sdk>=0.7.1"], "opentelemetry": ["opentelemetry-distro>=0.35b0"], From 8fbea536ef6dfd96f286e4fae07571299ef9b8eb Mon Sep 17 00:00:00 2001 From: Fabian Schindler Date: Tue, 21 Oct 2025 11:39:22 +0200 Subject: [PATCH 12/18] chore: add mcp to tox.ini --- scripts/populate_tox/config.py | 6 +++ scripts/populate_tox/releases.jsonl | 4 ++ .../split_tox_gh_actions.py | 1 + tox.ini | 37 +++++++++++++------ 4 files changed, 36 insertions(+), 12 deletions(-) diff --git a/scripts/populate_tox/config.py b/scripts/populate_tox/config.py index ecf1d94c5c..32a54c5ede 100644 --- a/scripts/populate_tox/config.py +++ b/scripts/populate_tox/config.py @@ -236,6 +236,12 @@ "package": "loguru", "num_versions": 2, }, + "mcp": { + "package": "mcp", + "deps": { + "*": ["pytest-asyncio", "mcp>=1.15.0"], + }, + }, "openai-base": { "package": "openai", "integration_name": "openai", diff --git a/scripts/populate_tox/releases.jsonl b/scripts/populate_tox/releases.jsonl index b55e77eb51..a5281fa48c 100644 --- a/scripts/populate_tox/releases.jsonl +++ b/scripts/populate_tox/releases.jsonl @@ -115,6 +115,10 @@ {"info": {"classifiers": ["Development Status :: 5 - Production/Stable", "Environment :: Web Environment", "Intended Audience :: Developers", "Intended Audience :: Information Technology", "Intended Audience :: System Administrators", "License :: OSI Approved :: MIT License", "Natural Language :: English", "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Topic :: Internet", "Topic :: Internet :: WWW/HTTP", "Topic :: Internet :: WWW/HTTP :: HTTP Servers", "Topic :: Software Development", "Topic :: Software Development :: Libraries", "Topic :: Software Development :: Libraries :: Application Frameworks", "Topic :: Software Development :: Libraries :: Python Modules", "Typing :: Typed"], "name": "litestar", "requires_python": "<4.0,>=3.8", "version": "2.18.0", "yanked": false}} {"info": {"classifiers": ["Development Status :: 5 - Production/Stable", "Environment :: Web Environment", "Intended Audience :: Developers", "Intended Audience :: Information Technology", "Intended Audience :: System Administrators", "License :: OSI Approved :: MIT License", "Natural Language :: English", "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Topic :: Internet", "Topic :: Internet :: WWW/HTTP", "Topic :: Internet :: WWW/HTTP :: HTTP Servers", "Topic :: Software Development", "Topic :: Software Development :: Libraries", "Topic :: Software Development :: Libraries :: Application Frameworks", "Topic :: Software Development :: Libraries :: Python Modules", "Typing :: Typed"], "name": "litestar", "requires_python": "<4.0,>=3.8", "version": "2.6.4", "yanked": false}} {"info": {"classifiers": ["Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Natural Language :: English", "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.5", "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy", "Topic :: System :: Logging"], "name": "loguru", "requires_python": "<4.0,>=3.5", "version": "0.7.3", "yanked": false}} +{"info": {"classifiers": ["Development Status :: 4 - Beta", "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13"], "name": "mcp", "requires_python": ">=3.10", "version": "1.15.0", "yanked": false}} +{"info": {"classifiers": ["Development Status :: 4 - Beta", "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13"], "name": "mcp", "requires_python": ">=3.10", "version": "1.16.0", "yanked": false}} +{"info": {"classifiers": ["Development Status :: 4 - Beta", "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13"], "name": "mcp", "requires_python": ">=3.10", "version": "1.17.0", "yanked": false}} +{"info": {"classifiers": ["Development Status :: 4 - Beta", "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13"], "name": "mcp", "requires_python": ">=3.10", "version": "1.18.0", "yanked": false}} {"info": {"classifiers": ["Intended Audience :: Developers", "Operating System :: MacOS", "Operating System :: Microsoft :: Windows", "Operating System :: OS Independent", "Operating System :: POSIX", "Operating System :: POSIX :: Linux", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Topic :: Software Development :: Libraries :: Python Modules", "Typing :: Typed"], "name": "openai", "requires_python": ">=3.7.1", "version": "1.0.1", "yanked": false}} {"info": {"classifiers": ["Intended Audience :: Developers", "License :: OSI Approved :: Apache Software License", "Operating System :: MacOS", "Operating System :: Microsoft :: Windows", "Operating System :: OS Independent", "Operating System :: POSIX", "Operating System :: POSIX :: Linux", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Topic :: Software Development :: Libraries :: Python Modules", "Typing :: Typed"], "name": "openai", "requires_python": ">=3.8", "version": "1.102.0", "yanked": false}} {"info": {"classifiers": ["Intended Audience :: Developers", "License :: OSI Approved :: Apache Software License", "Operating System :: MacOS", "Operating System :: Microsoft :: Windows", "Operating System :: OS Independent", "Operating System :: POSIX", "Operating System :: POSIX :: Linux", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Topic :: Software Development :: Libraries :: Python Modules", "Typing :: Typed"], "name": "openai", "requires_python": ">=3.8", "version": "1.109.1", "yanked": false}} diff --git a/scripts/split_tox_gh_actions/split_tox_gh_actions.py b/scripts/split_tox_gh_actions/split_tox_gh_actions.py index 9dea95842b..88750002c6 100755 --- a/scripts/split_tox_gh_actions/split_tox_gh_actions.py +++ b/scripts/split_tox_gh_actions/split_tox_gh_actions.py @@ -78,6 +78,7 @@ "langchain-notiktoken", "langgraph", "litellm", + "mcp", "openai-base", "openai-notiktoken", "openai_agents", diff --git a/tox.ini b/tox.ini index dee0d83365..2ec0bf0857 100644 --- a/tox.ini +++ b/tox.ini @@ -76,19 +76,24 @@ envlist = {py3.9,py3.11,py3.12}-langchain-notiktoken-v0.2.17 {py3.9,py3.12,py3.13}-langchain-notiktoken-v0.3.27 - {py3.9,py3.12,py3.13}-langgraph-v0.6.10 - {py3.10,py3.12,py3.13}-langgraph-v1.0.0 + {py3.9,py3.12,py3.13}-langgraph-v0.6.11 + {py3.10,py3.12,py3.13}-langgraph-v1.0.1 {py3.9,py3.12,py3.13}-litellm-v1.77.7 {py3.9,py3.12,py3.13}-litellm-v1.78.5 + {py3.10,py3.12,py3.13}-mcp-v1.15.0 + {py3.10,py3.12,py3.13}-mcp-v1.16.0 + {py3.10,py3.12,py3.13}-mcp-v1.17.0 + {py3.10,py3.12,py3.13}-mcp-v1.18.0 + {py3.8,py3.11,py3.12}-openai-base-v1.0.1 {py3.8,py3.12,py3.13}-openai-base-v1.109.1 - {py3.9,py3.12,py3.13}-openai-base-v2.5.0 + {py3.9,py3.12,py3.13}-openai-base-v2.6.0 {py3.8,py3.11,py3.12}-openai-notiktoken-v1.0.1 {py3.8,py3.12,py3.13}-openai-notiktoken-v1.109.1 - {py3.9,py3.12,py3.13}-openai-notiktoken-v2.5.0 + {py3.9,py3.12,py3.13}-openai-notiktoken-v2.6.0 {py3.10,py3.11,py3.12}-openai_agents-v0.0.19 {py3.10,py3.12,py3.13}-openai_agents-v0.1.0 @@ -280,10 +285,10 @@ envlist = {py3.6}-trytond-v4.8.18 {py3.6,py3.7,py3.8}-trytond-v5.8.16 {py3.8,py3.10,py3.11}-trytond-v6.8.17 - {py3.9,py3.12,py3.13}-trytond-v7.6.8 + {py3.9,py3.12,py3.13}-trytond-v7.6.9 {py3.7,py3.12,py3.13}-typer-v0.15.4 - {py3.8,py3.12,py3.13}-typer-v0.19.2 + {py3.8,py3.12,py3.13}-typer-v0.20.0 @@ -384,22 +389,29 @@ deps = langchain-notiktoken: langchain-openai langchain-notiktoken-v0.3.27: langchain-community - langgraph-v0.6.10: langgraph==0.6.10 - langgraph-v1.0.0: langgraph==1.0.0 + langgraph-v0.6.11: langgraph==0.6.11 + langgraph-v1.0.1: langgraph==1.0.1 litellm-v1.77.7: litellm==1.77.7 litellm-v1.78.5: litellm==1.78.5 + mcp-v1.15.0: mcp==1.15.0 + mcp-v1.16.0: mcp==1.16.0 + mcp-v1.17.0: mcp==1.17.0 + mcp-v1.18.0: mcp==1.18.0 + mcp: pytest-asyncio + mcp: mcp>=1.15.0 + openai-base-v1.0.1: openai==1.0.1 openai-base-v1.109.1: openai==1.109.1 - openai-base-v2.5.0: openai==2.5.0 + openai-base-v2.6.0: openai==2.6.0 openai-base: pytest-asyncio openai-base: tiktoken openai-base-v1.0.1: httpx<0.28 openai-notiktoken-v1.0.1: openai==1.0.1 openai-notiktoken-v1.109.1: openai==1.109.1 - openai-notiktoken-v2.5.0: openai==2.5.0 + openai-notiktoken-v2.6.0: openai==2.6.0 openai-notiktoken: pytest-asyncio openai-notiktoken-v1.0.1: httpx<0.28 @@ -715,13 +727,13 @@ deps = trytond-v4.8.18: trytond==4.8.18 trytond-v5.8.16: trytond==5.8.16 trytond-v6.8.17: trytond==6.8.17 - trytond-v7.6.8: trytond==7.6.8 + trytond-v7.6.9: trytond==7.6.9 trytond: werkzeug trytond-v4.6.22: werkzeug<1.0 trytond-v4.8.18: werkzeug<1.0 typer-v0.15.4: typer==0.15.4 - typer-v0.19.2: typer==0.19.2 + typer-v0.20.0: typer==0.20.0 @@ -777,6 +789,7 @@ setenv = litellm: TESTPATH=tests/integrations/litellm litestar: TESTPATH=tests/integrations/litestar loguru: TESTPATH=tests/integrations/loguru + mcp: TESTPATH=tests/integrations/mcp openai-base: TESTPATH=tests/integrations/openai openai-notiktoken: TESTPATH=tests/integrations/openai openai_agents: TESTPATH=tests/integrations/openai_agents From da2e2b30286a3f8817a96f1d1bbd087f1129c23a Mon Sep 17 00:00:00 2001 From: Fabian Schindler Date: Tue, 21 Oct 2025 13:21:45 +0200 Subject: [PATCH 13/18] fix: correctly handling PII data --- sentry_sdk/integrations/mcp.py | 30 ++++- tests/integrations/mcp/test_mcp.py | 181 +++++++++++++++++++++-------- 2 files changed, 157 insertions(+), 54 deletions(-) diff --git a/sentry_sdk/integrations/mcp.py b/sentry_sdk/integrations/mcp.py index 85303407cd..5e00c163c9 100644 --- a/sentry_sdk/integrations/mcp.py +++ b/sentry_sdk/integrations/mcp.py @@ -16,6 +16,7 @@ from sentry_sdk.consts import OP, SPANDATA from sentry_sdk.integrations import Integration, DidNotEnable from sentry_sdk.utils import safe_serialize +from sentry_sdk.scope import should_send_default_pii try: from mcp.server.lowlevel import Server @@ -166,10 +167,18 @@ def _set_span_output_data(span, result, result_data_key, handler_type): if result is None: return + # Get integration to check PII settings + integration = sentry_sdk.get_client().get_integration(MCPIntegration) + if integration is None: + return + + # Check if we should include sensitive data + should_include_data = should_send_default_pii() and integration.include_prompts + # For tools, extract the meaningful content if handler_type == "tool": extracted = _extract_tool_result_content(result) - if extracted is not None: + if extracted is not None and should_include_data: span.set_data(result_data_key, safe_serialize(extracted)) # Set content count if result is a dict if isinstance(extracted, dict): @@ -193,8 +202,8 @@ def _set_span_output_data(span, result, result_data_key, handler_type): if message_count > 0: span.set_data(SPANDATA.MCP_PROMPT_RESULT_MESSAGE_COUNT, message_count) - # Only set role and content for single-message prompts - if message_count == 1: + # Only set role and content for single-message prompts if PII is allowed + if message_count == 1 and should_include_data: first_message = messages[0] # Extract role role = None @@ -227,8 +236,8 @@ def _set_span_output_data(span, result, result_data_key, handler_type): if content_text: span.set_data(result_data_key, content_text) except Exception: - # Silently ignore if we can't extract message info, but still serialize result - span.set_data(result_data_key, safe_serialize(result)) + # Silently ignore if we can't extract message info + pass # Resources don't capture result content (result_data_key is None) @@ -524,6 +533,17 @@ class MCPIntegration(Integration): identifier = "mcp" origin = "auto.ai.mcp" + def __init__(self, include_prompts=True): + # type: (bool) -> None + """ + Initialize the MCP integration. + + Args: + include_prompts: Whether to include prompts (tool results and prompt content) + in span data. Requires send_default_pii=True. Default is True. + """ + self.include_prompts = include_prompts + @staticmethod def setup_once(): # type: () -> None diff --git a/tests/integrations/mcp/test_mcp.py b/tests/integrations/mcp/test_mcp.py index 684dec2c05..cc5fb19c22 100644 --- a/tests/integrations/mcp/test_mcp.py +++ b/tests/integrations/mcp/test_mcp.py @@ -112,11 +112,18 @@ def test_integration_patches_server(sentry_init): assert Server.read_resource is not original_read_resource -def test_tool_handler_sync(sentry_init, capture_events): +@pytest.mark.parametrize( + "send_default_pii, include_prompts", + [(True, True), (True, False), (False, True), (False, False)], +) +def test_tool_handler_sync( + sentry_init, capture_events, send_default_pii, include_prompts +): """Test that synchronous tool handlers create proper spans""" sentry_init( - integrations=[MCPIntegration()], + integrations=[MCPIntegration(include_prompts=include_prompts)], traces_sample_rate=1.0, + send_default_pii=send_default_pii, ) events = capture_events() @@ -152,21 +159,34 @@ def test_tool(tool_name, arguments): assert span["data"][SPANDATA.MCP_REQUEST_ID] == "req-123" assert span["data"]["mcp.request.argument.x"] == "10" assert span["data"]["mcp.request.argument.y"] == "5" - assert span["data"][SPANDATA.MCP_TOOL_RESULT_CONTENT] == json.dumps( - { - "result": "success", - "value": 42, - } - ) - assert span["data"][SPANDATA.MCP_TOOL_RESULT_CONTENT_COUNT] == 2 + + # Check PII-sensitive data is only present when both flags are True + if send_default_pii and include_prompts: + assert span["data"][SPANDATA.MCP_TOOL_RESULT_CONTENT] == json.dumps( + { + "result": "success", + "value": 42, + } + ) + assert span["data"][SPANDATA.MCP_TOOL_RESULT_CONTENT_COUNT] == 2 + else: + assert SPANDATA.MCP_TOOL_RESULT_CONTENT not in span["data"] + assert SPANDATA.MCP_TOOL_RESULT_CONTENT_COUNT not in span["data"] @pytest.mark.asyncio -async def test_tool_handler_async(sentry_init, capture_events): +@pytest.mark.parametrize( + "send_default_pii, include_prompts", + [(True, True), (True, False), (False, True), (False, False)], +) +async def test_tool_handler_async( + sentry_init, capture_events, send_default_pii, include_prompts +): """Test that async tool handlers create proper spans""" sentry_init( - integrations=[MCPIntegration()], + integrations=[MCPIntegration(include_prompts=include_prompts)], traces_sample_rate=1.0, + send_default_pii=send_default_pii, ) events = capture_events() @@ -203,9 +223,14 @@ async def test_tool_async(tool_name, arguments): assert span["data"][SPANDATA.MCP_REQUEST_ID] == "req-456" assert span["data"][SPANDATA.MCP_SESSION_ID] == "session-789" assert span["data"]["mcp.request.argument.data"] == '"test"' - assert span["data"][SPANDATA.MCP_TOOL_RESULT_CONTENT] == json.dumps( - {"status": "completed"} - ) + + # Check PII-sensitive data + if send_default_pii and include_prompts: + assert span["data"][SPANDATA.MCP_TOOL_RESULT_CONTENT] == json.dumps( + {"status": "completed"} + ) + else: + assert SPANDATA.MCP_TOOL_RESULT_CONTENT not in span["data"] def test_tool_handler_with_error(sentry_init, capture_events): @@ -249,11 +274,18 @@ def failing_tool(tool_name, arguments): assert span["tags"]["status"] == "internal_error" -def test_prompt_handler_sync(sentry_init, capture_events): +@pytest.mark.parametrize( + "send_default_pii, include_prompts", + [(True, True), (True, False), (False, True), (False, False)], +) +def test_prompt_handler_sync( + sentry_init, capture_events, send_default_pii, include_prompts +): """Test that synchronous prompt handlers create proper spans""" sentry_init( - integrations=[MCPIntegration()], + integrations=[MCPIntegration(include_prompts=include_prompts)], traces_sample_rate=1.0, + send_default_pii=send_default_pii, ) events = capture_events() @@ -289,21 +321,35 @@ def test_prompt(name, arguments): assert span["data"][SPANDATA.MCP_REQUEST_ID] == "req-prompt" assert span["data"]["mcp.request.argument.name"] == '"code_help"' assert span["data"]["mcp.request.argument.language"] == '"python"' - # For single message prompts, role and content should be captured + + # Message count is always captured assert span["data"][SPANDATA.MCP_PROMPT_RESULT_MESSAGE_COUNT] == 1 - assert span["data"][SPANDATA.MCP_PROMPT_RESULT_MESSAGE_ROLE] == "user" - assert ( - span["data"][SPANDATA.MCP_PROMPT_RESULT_MESSAGE_CONTENT] - == "Tell me about Python" - ) + + # For single message prompts, role and content should be captured only with PII + if send_default_pii and include_prompts: + assert span["data"][SPANDATA.MCP_PROMPT_RESULT_MESSAGE_ROLE] == "user" + assert ( + span["data"][SPANDATA.MCP_PROMPT_RESULT_MESSAGE_CONTENT] + == "Tell me about Python" + ) + else: + assert SPANDATA.MCP_PROMPT_RESULT_MESSAGE_ROLE not in span["data"] + assert SPANDATA.MCP_PROMPT_RESULT_MESSAGE_CONTENT not in span["data"] @pytest.mark.asyncio -async def test_prompt_handler_async(sentry_init, capture_events): +@pytest.mark.parametrize( + "send_default_pii, include_prompts", + [(True, True), (True, False), (False, True), (False, False)], +) +async def test_prompt_handler_async( + sentry_init, capture_events, send_default_pii, include_prompts +): """Test that async prompt handlers create proper spans""" sentry_init( - integrations=[MCPIntegration()], + integrations=[MCPIntegration(include_prompts=include_prompts)], traces_sample_rate=1.0, + send_default_pii=send_default_pii, ) events = capture_events() @@ -337,8 +383,9 @@ async def test_prompt_async(name, arguments): assert span["op"] == OP.MCP_SERVER assert span["description"] == "prompts/get mcp_info" - # For multi-message prompts, only count should be captured, not role/content + # For multi-message prompts, count is always captured assert span["data"][SPANDATA.MCP_PROMPT_RESULT_MESSAGE_COUNT] == 2 + # Role/content are never captured for multi-message prompts (even with PII) assert SPANDATA.MCP_PROMPT_RESULT_MESSAGE_ROLE not in span["data"] assert SPANDATA.MCP_PROMPT_RESULT_MESSAGE_CONTENT not in span["data"] @@ -487,11 +534,18 @@ def failing_resource(uri): assert error_event["exception"]["values"][0]["type"] == "FileNotFoundError" -def test_tool_result_extraction_tuple(sentry_init, capture_events): +@pytest.mark.parametrize( + "send_default_pii, include_prompts", + [(True, True), (False, False)], +) +def test_tool_result_extraction_tuple( + sentry_init, capture_events, send_default_pii, include_prompts +): """Test extraction of tool results from tuple format (UnstructuredContent, StructuredContent)""" sentry_init( - integrations=[MCPIntegration()], + integrations=[MCPIntegration(include_prompts=include_prompts)], traces_sample_rate=1.0, + send_default_pii=send_default_pii, ) events = capture_events() @@ -514,21 +568,32 @@ def test_tool_tuple(tool_name, arguments): (tx,) = events span = tx["spans"][0] - # Should extract the structured content (second element of tuple) - assert span["data"][SPANDATA.MCP_TOOL_RESULT_CONTENT] == json.dumps( - { - "key": "value", - "count": 5, - } - ) - assert span["data"][SPANDATA.MCP_TOOL_RESULT_CONTENT_COUNT] == 2 - - -def test_tool_result_extraction_unstructured(sentry_init, capture_events): + # Should extract the structured content (second element of tuple) only with PII + if send_default_pii and include_prompts: + assert span["data"][SPANDATA.MCP_TOOL_RESULT_CONTENT] == json.dumps( + { + "key": "value", + "count": 5, + } + ) + assert span["data"][SPANDATA.MCP_TOOL_RESULT_CONTENT_COUNT] == 2 + else: + assert SPANDATA.MCP_TOOL_RESULT_CONTENT not in span["data"] + assert SPANDATA.MCP_TOOL_RESULT_CONTENT_COUNT not in span["data"] + + +@pytest.mark.parametrize( + "send_default_pii, include_prompts", + [(True, True), (False, False)], +) +def test_tool_result_extraction_unstructured( + sentry_init, capture_events, send_default_pii, include_prompts +): """Test extraction of tool results from UnstructuredContent (list of content blocks)""" sentry_init( - integrations=[MCPIntegration()], + integrations=[MCPIntegration(include_prompts=include_prompts)], traces_sample_rate=1.0, + send_default_pii=send_default_pii, ) events = capture_events() @@ -552,8 +617,13 @@ def test_tool_unstructured(tool_name, arguments): (tx,) = events span = tx["spans"][0] - # Should extract and join text from content blocks - assert span["data"][SPANDATA.MCP_TOOL_RESULT_CONTENT] == '"First part Second part"' + # Should extract and join text from content blocks only with PII + if send_default_pii and include_prompts: + assert ( + span["data"][SPANDATA.MCP_TOOL_RESULT_CONTENT] == '"First part Second part"' + ) + else: + assert SPANDATA.MCP_TOOL_RESULT_CONTENT not in span["data"] def test_request_context_no_context(sentry_init, capture_events): @@ -665,11 +735,18 @@ def prompt1(name, arguments): assert "prompts/get prompt_a" in span_descriptions -def test_prompt_with_dict_result(sentry_init, capture_events): +@pytest.mark.parametrize( + "send_default_pii, include_prompts", + [(True, True), (False, False)], +) +def test_prompt_with_dict_result( + sentry_init, capture_events, send_default_pii, include_prompts +): """Test prompt handler with dict result instead of GetPromptResult object""" sentry_init( - integrations=[MCPIntegration()], + integrations=[MCPIntegration(include_prompts=include_prompts)], traces_sample_rate=1.0, + send_default_pii=send_default_pii, ) events = capture_events() @@ -694,10 +771,19 @@ def test_prompt_dict(name, arguments): (tx,) = events span = tx["spans"][0] - # Should still extract message info from dict format + # Message count is always captured assert span["data"][SPANDATA.MCP_PROMPT_RESULT_MESSAGE_COUNT] == 1 - assert span["data"][SPANDATA.MCP_PROMPT_RESULT_MESSAGE_ROLE] == "user" - assert span["data"][SPANDATA.MCP_PROMPT_RESULT_MESSAGE_CONTENT] == "Hello from dict" + + # Role and content only captured with PII + if send_default_pii and include_prompts: + assert span["data"][SPANDATA.MCP_PROMPT_RESULT_MESSAGE_ROLE] == "user" + assert ( + span["data"][SPANDATA.MCP_PROMPT_RESULT_MESSAGE_CONTENT] + == "Hello from dict" + ) + else: + assert SPANDATA.MCP_PROMPT_RESULT_MESSAGE_ROLE not in span["data"] + assert SPANDATA.MCP_PROMPT_RESULT_MESSAGE_CONTENT not in span["data"] def test_resource_without_protocol(sentry_init, capture_events): @@ -765,9 +851,6 @@ def test_tool_complex(tool_name, arguments): ) assert span["data"]["mcp.request.argument.string"] == '"test"' assert span["data"]["mcp.request.argument.number"] == "42" - assert span["data"][SPANDATA.MCP_TOOL_RESULT_CONTENT] == json.dumps( - {"processed": True} - ) @pytest.mark.asyncio From 0f003608119031aaa63037ca9e7b9f7c0cd40946 Mon Sep 17 00:00:00 2001 From: Fabian Schindler Date: Wed, 22 Oct 2025 15:27:06 +0200 Subject: [PATCH 14/18] Update scripts/populate_tox/config.py Co-authored-by: Ivana Kellyer --- scripts/populate_tox/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/populate_tox/config.py b/scripts/populate_tox/config.py index 32a54c5ede..518cb32e85 100644 --- a/scripts/populate_tox/config.py +++ b/scripts/populate_tox/config.py @@ -239,7 +239,7 @@ "mcp": { "package": "mcp", "deps": { - "*": ["pytest-asyncio", "mcp>=1.15.0"], + "*": ["pytest-asyncio"], }, }, "openai-base": { From f7f7b89552e311534d0527dc5b41e93be16ae672 Mon Sep 17 00:00:00 2001 From: Fabian Schindler Date: Thu, 23 Oct 2025 10:53:04 +0200 Subject: [PATCH 15/18] fix: moving Integration class to the top and removing setting span data from try blocks --- sentry_sdk/integrations/mcp.py | 64 ++++++++++++++++------------------ 1 file changed, 30 insertions(+), 34 deletions(-) diff --git a/sentry_sdk/integrations/mcp.py b/sentry_sdk/integrations/mcp.py index 5e00c163c9..7c0f84f67a 100644 --- a/sentry_sdk/integrations/mcp.py +++ b/sentry_sdk/integrations/mcp.py @@ -29,6 +29,30 @@ from typing import Any, Callable, Optional +class MCPIntegration(Integration): + identifier = "mcp" + origin = "auto.ai.mcp" + + def __init__(self, include_prompts=True): + # type: (bool) -> None + """ + Initialize the MCP integration. + + Args: + include_prompts: Whether to include prompts (tool results and prompt content) + in span data. Requires send_default_pii=True. Default is True. + """ + self.include_prompts = include_prompts + + @staticmethod + def setup_once(): + # type: () -> None + """ + Patches MCP server classes to instrument handler execution. + """ + _patch_lowlevel_server() + + def _get_request_context_data(): # type: () -> tuple[Optional[str], Optional[str], str] """ @@ -335,8 +359,6 @@ async def _async_handler_wrapper(handler_type, func, original_args): try: # Execute the async handler result = await func(*original_args) - _set_span_output_data(span, result, result_data_key, handler_type) - return result except Exception as e: # Set error flag for tools if handler_type == "tool": @@ -344,6 +366,9 @@ async def _async_handler_wrapper(handler_type, func, original_args): sentry_sdk.capture_exception(e) raise + _set_span_output_data(span, result, result_data_key, handler_type) + return result + def _sync_handler_wrapper(handler_type, func, original_args): # type: (str, Callable[..., Any], tuple[Any, ...]) -> Any @@ -399,8 +424,6 @@ def _sync_handler_wrapper(handler_type, func, original_args): try: # Execute the sync handler result = func(*original_args) - _set_span_output_data(span, result, result_data_key, handler_type) - return result except Exception as e: # Set error flag for tools if handler_type == "tool": @@ -408,6 +431,9 @@ def _sync_handler_wrapper(handler_type, func, original_args): sentry_sdk.capture_exception(e) raise + _set_span_output_data(span, result, result_data_key, handler_type) + return result + def _create_instrumented_handler(handler_type, func): # type: (str, Callable[..., Any]) -> Callable[..., Any] @@ -524,33 +550,3 @@ def patched_read_resource(self): )(func) Server.read_resource = patched_read_resource # type: ignore - - -# Integration class - - -class MCPIntegration(Integration): - identifier = "mcp" - origin = "auto.ai.mcp" - - def __init__(self, include_prompts=True): - # type: (bool) -> None - """ - Initialize the MCP integration. - - Args: - include_prompts: Whether to include prompts (tool results and prompt content) - in span data. Requires send_default_pii=True. Default is True. - """ - self.include_prompts = include_prompts - - @staticmethod - def setup_once(): - # type: () -> None - """ - Patches MCP server classes to instrument handler execution. - """ - _patch_lowlevel_server() - - -__all__ = ["MCPIntegration"] From 8d3a9aa8887a82664cf06abf2df47830622d7c59 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Thu, 23 Oct 2025 14:33:22 +0200 Subject: [PATCH 16/18] skip tests if not installed --- tests/integrations/mcp/__init__.py | 3 +++ tests/integrations/mcp/test_mcp.py | 7 ++----- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/integrations/mcp/__init__.py b/tests/integrations/mcp/__init__.py index e69de29bb2..01ef442500 100644 --- a/tests/integrations/mcp/__init__.py +++ b/tests/integrations/mcp/__init__.py @@ -0,0 +1,3 @@ +import pytest + +pytest.importorskip("mcp") diff --git a/tests/integrations/mcp/test_mcp.py b/tests/integrations/mcp/test_mcp.py index cc5fb19c22..7b956a6f31 100644 --- a/tests/integrations/mcp/test_mcp.py +++ b/tests/integrations/mcp/test_mcp.py @@ -32,11 +32,8 @@ async def __call__(self, *args, **kwargs): from sentry_sdk.consts import SPANDATA, OP from sentry_sdk.integrations.mcp import MCPIntegration -try: - from mcp.server.lowlevel import Server - from mcp.server.lowlevel.server import request_ctx -except ImportError: - pytest.skip("MCP not installed", allow_module_level=True) +from mcp.server.lowlevel import Server +from mcp.server.lowlevel.server import request_ctx # Mock MCP types and structures From 63568b66c53e46f82764a1a740b42070c62cc9be Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Thu, 23 Oct 2025 14:46:00 +0200 Subject: [PATCH 17/18] fix mypy --- sentry_sdk/integrations/mcp.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/sentry_sdk/integrations/mcp.py b/sentry_sdk/integrations/mcp.py index 7c0f84f67a..2a2d440616 100644 --- a/sentry_sdk/integrations/mcp.py +++ b/sentry_sdk/integrations/mcp.py @@ -19,8 +19,8 @@ from sentry_sdk.scope import should_send_default_pii try: - from mcp.server.lowlevel import Server - from mcp.server.lowlevel.server import request_ctx + from mcp.server.lowlevel import Server # type: ignore[import-not-found] + from mcp.server.lowlevel.server import request_ctx # type: ignore[import-not-found] except ImportError: raise DidNotEnable("MCP SDK not installed") @@ -210,7 +210,7 @@ def _set_span_output_data(span, result, result_data_key, handler_type): elif handler_type == "prompt": # For prompts, count messages and set role/content only for single-message prompts try: - messages = None + messages = None # type: Optional[list[str]] message_count = 0 # Check if result has messages attribute (GetPromptResult) @@ -227,7 +227,7 @@ def _set_span_output_data(span, result, result_data_key, handler_type): span.set_data(SPANDATA.MCP_PROMPT_RESULT_MESSAGE_COUNT, message_count) # Only set role and content for single-message prompts if PII is allowed - if message_count == 1 and should_include_data: + if message_count == 1 and should_include_data and messages: first_message = messages[0] # Extract role role = None @@ -525,7 +525,7 @@ def patched_call_tool(self, **kwargs): original_call_tool, "tool", self, **kwargs )(func) - Server.call_tool = patched_call_tool # type: ignore + Server.call_tool = patched_call_tool # Patch get_prompt decorator original_get_prompt = Server.get_prompt @@ -537,7 +537,7 @@ def patched_get_prompt(self): original_get_prompt, "prompt", self )(func) - Server.get_prompt = patched_get_prompt # type: ignore + Server.get_prompt = patched_get_prompt # Patch read_resource decorator original_read_resource = Server.read_resource @@ -549,4 +549,4 @@ def patched_read_resource(self): original_read_resource, "resource", self )(func) - Server.read_resource = patched_read_resource # type: ignore + Server.read_resource = patched_read_resource From aa6d1249f0a09b32e27f826703bb9ba73b222bb3 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Thu, 23 Oct 2025 14:59:26 +0200 Subject: [PATCH 18/18] . --- tests/integrations/mcp/conftest.py | 25 ------------------------- tests/integrations/mcp/test_mcp.py | 28 ++++++++++++++++++++++++++-- 2 files changed, 26 insertions(+), 27 deletions(-) delete mode 100644 tests/integrations/mcp/conftest.py diff --git a/tests/integrations/mcp/conftest.py b/tests/integrations/mcp/conftest.py deleted file mode 100644 index 8458617066..0000000000 --- a/tests/integrations/mcp/conftest.py +++ /dev/null @@ -1,25 +0,0 @@ -import pytest - -try: - from mcp.server.lowlevel.server import request_ctx -except ImportError: - request_ctx = None - - -@pytest.fixture(autouse=True) -def reset_request_ctx(): - """Reset request context before and after each test""" - if request_ctx is not None: - try: - if request_ctx.get() is not None: - request_ctx.set(None) - except LookupError: - pass - - yield - - if request_ctx is not None: - try: - request_ctx.set(None) - except LookupError: - pass diff --git a/tests/integrations/mcp/test_mcp.py b/tests/integrations/mcp/test_mcp.py index 7b956a6f31..738fdedf48 100644 --- a/tests/integrations/mcp/test_mcp.py +++ b/tests/integrations/mcp/test_mcp.py @@ -28,12 +28,36 @@ async def __call__(self, *args, **kwargs): return super(AsyncMock, self).__call__(*args, **kwargs) +from mcp.server.lowlevel import Server +from mcp.server.lowlevel.server import request_ctx + +try: + from mcp.server.lowlevel.server import request_ctx +except ImportError: + request_ctx = None + from sentry_sdk import start_transaction from sentry_sdk.consts import SPANDATA, OP from sentry_sdk.integrations.mcp import MCPIntegration -from mcp.server.lowlevel import Server -from mcp.server.lowlevel.server import request_ctx + +@pytest.fixture(autouse=True) +def reset_request_ctx(): + """Reset request context before and after each test""" + if request_ctx is not None: + try: + if request_ctx.get() is not None: + request_ctx.set(None) + except LookupError: + pass + + yield + + if request_ctx is not None: + try: + request_ctx.set(None) + except LookupError: + pass # Mock MCP types and structures