From bf29c56a8830ea034c8446c474158f5778b1a0ec Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Mon, 15 Dec 2025 11:27:58 +0100 Subject: [PATCH 01/10] Patch FastMCP --- sentry_sdk/integrations/mcp.py | 105 +++++++++++++++++++++++++++++++++ 1 file changed, 105 insertions(+) diff --git a/sentry_sdk/integrations/mcp.py b/sentry_sdk/integrations/mcp.py index 7b72aa4763..88830c8b78 100644 --- a/sentry_sdk/integrations/mcp.py +++ b/sentry_sdk/integrations/mcp.py @@ -24,6 +24,11 @@ except ImportError: raise DidNotEnable("MCP SDK not installed") +try: + from fastmcp import FastMCP +except ImportError: + FastMCP = None + if TYPE_CHECKING: from typing import Any, Callable, Optional @@ -52,6 +57,9 @@ def setup_once(): """ _patch_lowlevel_server() + if FastMCP is not None: + _patch_fastmcp() + def _get_request_context_data(): # type: () -> tuple[Optional[str], Optional[str], str] @@ -564,3 +572,100 @@ def patched_read_resource(self): )(func) Server.read_resource = patched_read_resource + + +def _patch_fastmcp(): + # type: () -> None + """ + Patches the standalone fastmcp package's FastMCP class. + + The standalone fastmcp package (v2.14.0+) registers its own handlers for + prompts and resources directly, bypassing the Server decorators we patch. + This function patches the _get_prompt_mcp and _read_resource_mcp methods + to add instrumentation for those handlers. + """ + if hasattr(FastMCP, "_get_prompt_mcp"): + original_get_prompt_mcp = FastMCP._get_prompt_mcp + + @wraps(original_get_prompt_mcp) + async def patched_get_prompt_mcp(self, name, arguments=None): + # type: (Any, str, Optional[dict[str, Any]]) -> Any + return await _async_fastmcp_handler_wrapper( + "prompt", + lambda n, a: original_get_prompt_mcp(self, n, a), + (name, arguments), + ) + + FastMCP._get_prompt_mcp = patched_get_prompt_mcp + + # Patch _read_resource_mcp + if hasattr(FastMCP, "_read_resource_mcp"): + original_read_resource_mcp = FastMCP._read_resource_mcp + + @wraps(original_read_resource_mcp) + async def patched_read_resource_mcp(self, uri): + # type: (Any, Any) -> Any + return await _async_fastmcp_handler_wrapper( + "resource", + lambda u: original_read_resource_mcp(self, u), + (uri,), + ) + + FastMCP._read_resource_mcp = patched_read_resource_mcp + + +async def _async_fastmcp_handler_wrapper(handler_type, func, original_args): + # type: (str, Callable[..., Any], tuple[Any, ...]) -> Any + """ + Async wrapper for standalone FastMCP handlers. + + Similar to _async_handler_wrapper but the original function is already + a coroutine function that we call directly. + """ + ( + handler_name, + arguments, + span_data_key, + span_name, + mcp_method_name, + result_data_key, + ) = _prepare_handler_data(handler_type, original_args) + + with get_start_span_function()( + op=OP.MCP_SERVER, + name=span_name, + origin=MCPIntegration.origin, + ) as span: + request_id, session_id, mcp_transport = _get_request_context_data() + + _set_span_input_data( + span, + handler_name, + span_data_key, + mcp_method_name, + arguments, + request_id, + session_id, + mcp_transport, + ) + + 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: + result = await func(*original_args) + except Exception as e: + if handler_type == "tool": + span.set_data(SPANDATA.MCP_TOOL_RESULT_IS_ERROR, True) + sentry_sdk.capture_exception(e) + raise + + _set_span_output_data(span, result, result_data_key, handler_type) + return result From 66c192c57974a8ae94818426368542bbd04125d5 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Mon, 15 Dec 2025 13:45:16 +0100 Subject: [PATCH 02/10] mypy --- sentry_sdk/integrations/mcp.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sentry_sdk/integrations/mcp.py b/sentry_sdk/integrations/mcp.py index 88830c8b78..376181b6a5 100644 --- a/sentry_sdk/integrations/mcp.py +++ b/sentry_sdk/integrations/mcp.py @@ -25,7 +25,7 @@ raise DidNotEnable("MCP SDK not installed") try: - from fastmcp import FastMCP + from fastmcp import FastMCP # type: ignore[import-not-found] except ImportError: FastMCP = None From 79e5779c3717cb86c1be70b08d9edfdd98ee4bcb Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Mon, 15 Dec 2025 14:24:38 +0100 Subject: [PATCH 03/10] cleanup --- sentry_sdk/integrations/mcp.py | 132 +++++++++++++-------------------- 1 file changed, 53 insertions(+), 79 deletions(-) diff --git a/sentry_sdk/integrations/mcp.py b/sentry_sdk/integrations/mcp.py index 376181b6a5..fe14138554 100644 --- a/sentry_sdk/integrations/mcp.py +++ b/sentry_sdk/integrations/mcp.py @@ -290,26 +290,51 @@ def _set_span_output_data(span, result, result_data_key, handler_type): # 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, Optional[str]] +def _prepare_handler_data(handler_type, original_args, original_kwargs=None): + # type: (str, tuple[Any, ...], Optional[dict[str, Any]]) -> tuple[str, dict[str, Any], str, str, str, Optional[str]] """ 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) """ + original_kwargs = original_kwargs or {} + # 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 {} + if original_args: + handler_name = original_args[0] + elif original_kwargs.get("tool_name"): + handler_name = original_kwargs["tool_name"] + + arguments = {} + if len(original_args) > 1: + arguments = original_args[1] + elif original_kwargs.get("arguments"): + arguments = original_kwargs["arguments"] + elif handler_type == "prompt": - handler_name = original_args[0] # name - arguments = original_args[1] if len(original_args) > 1 else {} + if original_args: + handler_name = original_args[0] + elif original_kwargs.get("tool_name"): + handler_name = original_kwargs["tool_name"] + + arguments = {} + if len(original_args) > 1: + arguments = original_args[1] + elif original_kwargs.get("arguments"): + arguments = original_kwargs["arguments"] + # 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" + handler_name = "unknown" + if original_args: + handler_name = str(original_args[0]) + elif original_kwargs.get("uri"): + handler_name = str(original_kwargs["uri"]) + arguments = {} # Get span configuration @@ -327,8 +352,10 @@ def _prepare_handler_data(handler_type, original_args): ) -async def _async_handler_wrapper(handler_type, func, original_args): - # type: (str, Callable[..., Any], tuple[Any, ...]) -> Any +async def _async_handler_wrapper( + handler_type, func, original_args, original_kwargs=None +): + # type: (str, Callable[..., Any], tuple[Any, ...], Optional[dict[Any, Any]]) -> Any """ Async wrapper for MCP handlers. @@ -337,6 +364,9 @@ async def _async_handler_wrapper(handler_type, func, original_args): func: The async handler function to wrap original_args: Original arguments passed to the handler """ + if original_kwargs is None: + original_kwargs = {} + ( handler_name, arguments, @@ -344,7 +374,7 @@ async def _async_handler_wrapper(handler_type, func, original_args): span_name, mcp_method_name, result_data_key, - ) = _prepare_handler_data(handler_type, original_args) + ) = _prepare_handler_data(handler_type, original_args, original_kwargs) # Start span and execute with get_start_span_function()( @@ -380,7 +410,7 @@ async def _async_handler_wrapper(handler_type, func, original_args): try: # Execute the async handler - result = await func(*original_args) + result = await func(*original_args, **original_kwargs) except Exception as e: # Set error flag for tools if handler_type == "tool": @@ -588,84 +618,28 @@ def _patch_fastmcp(): original_get_prompt_mcp = FastMCP._get_prompt_mcp @wraps(original_get_prompt_mcp) - async def patched_get_prompt_mcp(self, name, arguments=None): - # type: (Any, str, Optional[dict[str, Any]]) -> Any - return await _async_fastmcp_handler_wrapper( + async def patched_get_prompt_mcp(self, *args, **kwargs): + # type: (Any, Any) -> Any + return await _async_handler_wrapper( "prompt", - lambda n, a: original_get_prompt_mcp(self, n, a), - (name, arguments), + original_get_prompt_mcp, + args, + kwargs, ) FastMCP._get_prompt_mcp = patched_get_prompt_mcp - # Patch _read_resource_mcp if hasattr(FastMCP, "_read_resource_mcp"): original_read_resource_mcp = FastMCP._read_resource_mcp @wraps(original_read_resource_mcp) - async def patched_read_resource_mcp(self, uri): + async def patched_read_resource_mcp(self, *args, **kwargs): # type: (Any, Any) -> Any - return await _async_fastmcp_handler_wrapper( + return await _async_handler_wrapper( "resource", - lambda u: original_read_resource_mcp(self, u), - (uri,), + original_read_resource_mcp, + args, + kwargs, ) FastMCP._read_resource_mcp = patched_read_resource_mcp - - -async def _async_fastmcp_handler_wrapper(handler_type, func, original_args): - # type: (str, Callable[..., Any], tuple[Any, ...]) -> Any - """ - Async wrapper for standalone FastMCP handlers. - - Similar to _async_handler_wrapper but the original function is already - a coroutine function that we call directly. - """ - ( - handler_name, - arguments, - span_data_key, - span_name, - mcp_method_name, - result_data_key, - ) = _prepare_handler_data(handler_type, original_args) - - with get_start_span_function()( - op=OP.MCP_SERVER, - name=span_name, - origin=MCPIntegration.origin, - ) as span: - request_id, session_id, mcp_transport = _get_request_context_data() - - _set_span_input_data( - span, - handler_name, - span_data_key, - mcp_method_name, - arguments, - request_id, - session_id, - mcp_transport, - ) - - 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: - result = await func(*original_args) - except Exception as e: - if handler_type == "tool": - span.set_data(SPANDATA.MCP_TOOL_RESULT_IS_ERROR, True) - sentry_sdk.capture_exception(e) - raise - - _set_span_output_data(span, result, result_data_key, handler_type) - return result From 8d04ba7711adba4c501692e888901e1ea8202524 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Mon, 15 Dec 2025 14:36:12 +0100 Subject: [PATCH 04/10] fix --- sentry_sdk/integrations/mcp.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sentry_sdk/integrations/mcp.py b/sentry_sdk/integrations/mcp.py index 2bfd74088f..819705e3d6 100644 --- a/sentry_sdk/integrations/mcp.py +++ b/sentry_sdk/integrations/mcp.py @@ -290,7 +290,7 @@ def _set_span_output_data( def _prepare_handler_data( handler_type: str, original_args: "tuple[Any, ...]", - original_kwargs: "Optional[dict[str, Any]]", + original_kwargs: "Optional[dict[str, Any]]" = None, ) -> "tuple[str, dict[str, Any], str, str, str, Optional[str]]": """ Prepare common handler data for both async and sync wrappers. From 69c6cad009a1af782d1030ebb4ac6f68387697c1 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Mon, 15 Dec 2025 14:40:39 +0100 Subject: [PATCH 05/10] fix old-style types --- sentry_sdk/integrations/mcp.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/sentry_sdk/integrations/mcp.py b/sentry_sdk/integrations/mcp.py index 819705e3d6..227a691f6c 100644 --- a/sentry_sdk/integrations/mcp.py +++ b/sentry_sdk/integrations/mcp.py @@ -623,8 +623,7 @@ def _patch_fastmcp(): original_get_prompt_mcp = FastMCP._get_prompt_mcp @wraps(original_get_prompt_mcp) - async def patched_get_prompt_mcp(self, *args, **kwargs): - # type: (Any, Any) -> Any + async def patched_get_prompt_mcp(self: Any, *args: Any, **kwargs: Any) -> Any: return await _async_handler_wrapper( "prompt", original_get_prompt_mcp, @@ -638,8 +637,9 @@ async def patched_get_prompt_mcp(self, *args, **kwargs): original_read_resource_mcp = FastMCP._read_resource_mcp @wraps(original_read_resource_mcp) - async def patched_read_resource_mcp(self, *args, **kwargs): - # type: (Any, Any) -> Any + async def patched_read_resource_mcp( + self: Any, *args: Any, **kwargs: Any + ) -> Any: return await _async_handler_wrapper( "resource", original_read_resource_mcp, From 1822003977ac1fec29ceb39657e7fce7fc7e6d51 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Mon, 15 Dec 2025 14:46:34 +0100 Subject: [PATCH 06/10] . --- sentry_sdk/integrations/mcp.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/sentry_sdk/integrations/mcp.py b/sentry_sdk/integrations/mcp.py index 227a691f6c..25ee76dcfb 100644 --- a/sentry_sdk/integrations/mcp.py +++ b/sentry_sdk/integrations/mcp.py @@ -623,7 +623,9 @@ def _patch_fastmcp(): original_get_prompt_mcp = FastMCP._get_prompt_mcp @wraps(original_get_prompt_mcp) - async def patched_get_prompt_mcp(self: Any, *args: Any, **kwargs: Any) -> Any: + async def patched_get_prompt_mcp( + self: "Any", *args: "Any", **kwargs: "Any" + ) -> "Any": return await _async_handler_wrapper( "prompt", original_get_prompt_mcp, @@ -638,8 +640,8 @@ async def patched_get_prompt_mcp(self: Any, *args: Any, **kwargs: Any) -> Any: @wraps(original_read_resource_mcp) async def patched_read_resource_mcp( - self: Any, *args: Any, **kwargs: Any - ) -> Any: + self: "Any", *args: "Any", **kwargs: "Any" + ) -> "Any": return await _async_handler_wrapper( "resource", original_read_resource_mcp, From 1f8745093a72ab8e357e3b918552c2a24f59e5b9 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Mon, 15 Dec 2025 16:04:44 +0100 Subject: [PATCH 07/10] . --- 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 25ee76dcfb..d89045a1e3 100644 --- a/sentry_sdk/integrations/mcp.py +++ b/sentry_sdk/integrations/mcp.py @@ -401,7 +401,11 @@ async def _async_handler_wrapper( # For resources, extract and set protocol if handler_type == "resource": - uri = original_args[0] + if original_args: + uri = original_args[0] + else: + uri = original_kwargs.get("uri") + protocol = None if hasattr(uri, "scheme"): protocol = uri.scheme @@ -623,9 +627,7 @@ def _patch_fastmcp(): original_get_prompt_mcp = FastMCP._get_prompt_mcp @wraps(original_get_prompt_mcp) - async def patched_get_prompt_mcp( - self: "Any", *args: "Any", **kwargs: "Any" - ) -> "Any": + async def patched_get_prompt_mcp(*args: "Any", **kwargs: "Any") -> "Any": return await _async_handler_wrapper( "prompt", original_get_prompt_mcp, @@ -639,9 +641,7 @@ async def patched_get_prompt_mcp( original_read_resource_mcp = FastMCP._read_resource_mcp @wraps(original_read_resource_mcp) - async def patched_read_resource_mcp( - self: "Any", *args: "Any", **kwargs: "Any" - ) -> "Any": + async def patched_read_resource_mcp(*args: "Any", **kwargs: "Any") -> "Any": return await _async_handler_wrapper( "resource", original_read_resource_mcp, From 7c161d6b05bac093f73fbff7bafedacc75291ce6 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Mon, 15 Dec 2025 16:41:37 +0100 Subject: [PATCH 08/10] bound methods --- sentry_sdk/integrations/mcp.py | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/sentry_sdk/integrations/mcp.py b/sentry_sdk/integrations/mcp.py index d89045a1e3..7f69bdb85c 100644 --- a/sentry_sdk/integrations/mcp.py +++ b/sentry_sdk/integrations/mcp.py @@ -316,8 +316,8 @@ def _prepare_handler_data( elif handler_type == "prompt": if original_args: handler_name = original_args[0] - elif original_kwargs.get("tool_name"): - handler_name = original_kwargs["tool_name"] + elif original_kwargs.get("name"): + handler_name = original_kwargs["name"] arguments = {} if len(original_args) > 1: @@ -357,6 +357,7 @@ async def _async_handler_wrapper( func: "Callable[..., Any]", original_args: "tuple[Any, ...]", original_kwargs: "Optional[dict[str, Any]]" = None, + self: "Optional[Any]" = None, ) -> "Any": """ Async wrapper for MCP handlers. @@ -365,6 +366,8 @@ async def _async_handler_wrapper( handler_type: "tool", "prompt", or "resource" func: The async handler function to wrap original_args: Original arguments passed to the handler + original_kwargs: Original keyword arguments passed to the handler + self: Optional instance for bound methods """ if original_kwargs is None: original_kwargs = {} @@ -416,6 +419,8 @@ async def _async_handler_wrapper( try: # Execute the async handler + if self is not None: + original_args = (self, *original_args) result = await func(*original_args, **original_kwargs) except Exception as e: # Set error flag for tools @@ -627,12 +632,13 @@ def _patch_fastmcp(): original_get_prompt_mcp = FastMCP._get_prompt_mcp @wraps(original_get_prompt_mcp) - async def patched_get_prompt_mcp(*args: "Any", **kwargs: "Any") -> "Any": + async def patched_get_prompt_mcp(self, *args: "Any", **kwargs: "Any") -> "Any": return await _async_handler_wrapper( "prompt", original_get_prompt_mcp, args, kwargs, + self, ) FastMCP._get_prompt_mcp = patched_get_prompt_mcp @@ -641,12 +647,15 @@ async def patched_get_prompt_mcp(*args: "Any", **kwargs: "Any") -> "Any": original_read_resource_mcp = FastMCP._read_resource_mcp @wraps(original_read_resource_mcp) - async def patched_read_resource_mcp(*args: "Any", **kwargs: "Any") -> "Any": + async def patched_read_resource_mcp( + self, *args: "Any", **kwargs: "Any" + ) -> "Any": return await _async_handler_wrapper( "resource", original_read_resource_mcp, args, kwargs, + self, ) FastMCP._read_resource_mcp = patched_read_resource_mcp From 0dcc393ef954a3539c717e657089584c81566b2d Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Mon, 15 Dec 2025 16:44:05 +0100 Subject: [PATCH 09/10] mypy --- sentry_sdk/integrations/mcp.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/sentry_sdk/integrations/mcp.py b/sentry_sdk/integrations/mcp.py index 7f69bdb85c..ade40be122 100644 --- a/sentry_sdk/integrations/mcp.py +++ b/sentry_sdk/integrations/mcp.py @@ -632,7 +632,9 @@ def _patch_fastmcp(): original_get_prompt_mcp = FastMCP._get_prompt_mcp @wraps(original_get_prompt_mcp) - async def patched_get_prompt_mcp(self, *args: "Any", **kwargs: "Any") -> "Any": + async def patched_get_prompt_mcp( + self: "Any", *args: "Any", **kwargs: "Any" + ) -> "Any": return await _async_handler_wrapper( "prompt", original_get_prompt_mcp, @@ -648,7 +650,7 @@ async def patched_get_prompt_mcp(self, *args: "Any", **kwargs: "Any") -> "Any": @wraps(original_read_resource_mcp) async def patched_read_resource_mcp( - self, *args: "Any", **kwargs: "Any" + self: "Any", *args: "Any", **kwargs: "Any" ) -> "Any": return await _async_handler_wrapper( "resource", From 03b798b7c1ed4042b079bfe1890b08c263d784c9 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Tue, 16 Dec 2025 08:47:09 +0100 Subject: [PATCH 10/10] fix arg name --- sentry_sdk/integrations/mcp.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/sentry_sdk/integrations/mcp.py b/sentry_sdk/integrations/mcp.py index ade40be122..6a7edbb7ba 100644 --- a/sentry_sdk/integrations/mcp.py +++ b/sentry_sdk/integrations/mcp.py @@ -304,8 +304,8 @@ def _prepare_handler_data( if handler_type == "tool": if original_args: handler_name = original_args[0] - elif original_kwargs.get("tool_name"): - handler_name = original_kwargs["tool_name"] + elif original_kwargs.get("name"): + handler_name = original_kwargs["name"] arguments = {} if len(original_args) > 1: