mcp.py 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636
  1. """
  2. Sentry integration for MCP (Model Context Protocol) servers.
  3. This integration instruments MCP servers to create spans for tool, prompt,
  4. and resource handler execution, and captures errors that occur during execution.
  5. Supports the low-level `mcp.server.lowlevel.Server` API.
  6. """
  7. import inspect
  8. from functools import wraps
  9. from typing import TYPE_CHECKING
  10. import sentry_sdk
  11. from sentry_sdk.ai.utils import get_start_span_function
  12. from sentry_sdk.consts import OP, SPANDATA
  13. from sentry_sdk.integrations import Integration, DidNotEnable
  14. from sentry_sdk.utils import safe_serialize
  15. from sentry_sdk.scope import should_send_default_pii
  16. from sentry_sdk.integrations._wsgi_common import nullcontext
  17. try:
  18. from mcp.server.lowlevel import Server # type: ignore[import-not-found]
  19. from mcp.server.lowlevel.server import request_ctx # type: ignore[import-not-found]
  20. from mcp.server.streamable_http import StreamableHTTPServerTransport # type: ignore[import-not-found]
  21. except ImportError:
  22. raise DidNotEnable("MCP SDK not installed")
  23. try:
  24. from fastmcp import FastMCP # type: ignore[import-not-found]
  25. except ImportError:
  26. FastMCP = None
  27. if TYPE_CHECKING:
  28. from typing import Any, Callable, Optional, Tuple, ContextManager
  29. from starlette.types import Receive, Scope, Send # type: ignore[import-not-found]
  30. class MCPIntegration(Integration):
  31. identifier = "mcp"
  32. origin = "auto.ai.mcp"
  33. def __init__(self, include_prompts: bool = True) -> None:
  34. """
  35. Initialize the MCP integration.
  36. Args:
  37. include_prompts: Whether to include prompts (tool results and prompt content)
  38. in span data. Requires send_default_pii=True. Default is True.
  39. """
  40. self.include_prompts = include_prompts
  41. @staticmethod
  42. def setup_once() -> None:
  43. """
  44. Patches MCP server classes to instrument handler execution.
  45. """
  46. _patch_lowlevel_server()
  47. _patch_handle_request()
  48. if FastMCP is not None:
  49. _patch_fastmcp()
  50. def _get_active_http_scopes() -> (
  51. "Optional[Tuple[Optional[sentry_sdk.Scope], Optional[sentry_sdk.Scope]]]"
  52. ):
  53. try:
  54. ctx = request_ctx.get()
  55. except LookupError:
  56. return None
  57. if (
  58. ctx is None
  59. or not hasattr(ctx, "request")
  60. or ctx.request is None
  61. or "state" not in ctx.request.scope
  62. ):
  63. return None
  64. return (
  65. ctx.request.scope["state"].get("sentry_sdk.isolation_scope"),
  66. ctx.request.scope["state"].get("sentry_sdk.current_scope"),
  67. )
  68. def _get_request_context_data() -> "tuple[Optional[str], Optional[str], str]":
  69. """
  70. Extract request ID, session ID, and MCP transport type from the request context.
  71. Returns:
  72. Tuple of (request_id, session_id, mcp_transport).
  73. - request_id: May be None if not available
  74. - session_id: May be None if not available
  75. - mcp_transport: "http", "sse", "stdio"
  76. """
  77. request_id: "Optional[str]" = None
  78. session_id: "Optional[str]" = None
  79. mcp_transport: str = "stdio"
  80. try:
  81. ctx = request_ctx.get()
  82. if ctx is not None:
  83. request_id = ctx.request_id
  84. if hasattr(ctx, "request") and ctx.request is not None:
  85. request = ctx.request
  86. # Detect transport type by checking request characteristics
  87. if hasattr(request, "query_params") and request.query_params.get(
  88. "session_id"
  89. ):
  90. # SSE transport uses query parameter
  91. mcp_transport = "sse"
  92. session_id = request.query_params.get("session_id")
  93. elif hasattr(request, "headers") and request.headers.get(
  94. "mcp-session-id"
  95. ):
  96. # StreamableHTTP transport uses header
  97. mcp_transport = "http"
  98. session_id = request.headers.get("mcp-session-id")
  99. except LookupError:
  100. # No request context available - default to stdio
  101. pass
  102. return request_id, session_id, mcp_transport
  103. def _get_span_config(
  104. handler_type: str, item_name: str
  105. ) -> "tuple[str, str, str, Optional[str]]":
  106. """
  107. Get span configuration based on handler type.
  108. Returns:
  109. Tuple of (span_data_key, span_name, mcp_method_name, result_data_key)
  110. Note: result_data_key is None for resources
  111. """
  112. if handler_type == "tool":
  113. span_data_key = SPANDATA.MCP_TOOL_NAME
  114. mcp_method_name = "tools/call"
  115. result_data_key = SPANDATA.MCP_TOOL_RESULT_CONTENT
  116. elif handler_type == "prompt":
  117. span_data_key = SPANDATA.MCP_PROMPT_NAME
  118. mcp_method_name = "prompts/get"
  119. result_data_key = SPANDATA.MCP_PROMPT_RESULT_MESSAGE_CONTENT
  120. else: # resource
  121. span_data_key = SPANDATA.MCP_RESOURCE_URI
  122. mcp_method_name = "resources/read"
  123. result_data_key = None # Resources don't capture result content
  124. span_name = f"{mcp_method_name} {item_name}"
  125. return span_data_key, span_name, mcp_method_name, result_data_key
  126. def _set_span_input_data(
  127. span: "Any",
  128. handler_name: str,
  129. span_data_key: str,
  130. mcp_method_name: str,
  131. arguments: "dict[str, Any]",
  132. request_id: "Optional[str]",
  133. session_id: "Optional[str]",
  134. mcp_transport: str,
  135. ) -> None:
  136. """Set input span data for MCP handlers."""
  137. # Set handler identifier
  138. span.set_data(span_data_key, handler_name)
  139. span.set_data(SPANDATA.MCP_METHOD_NAME, mcp_method_name)
  140. # Set transport/MCP transport type
  141. span.set_data(
  142. SPANDATA.NETWORK_TRANSPORT, "pipe" if mcp_transport == "stdio" else "tcp"
  143. )
  144. span.set_data(SPANDATA.MCP_TRANSPORT, mcp_transport)
  145. # Set request_id if provided
  146. if request_id:
  147. span.set_data(SPANDATA.MCP_REQUEST_ID, request_id)
  148. # Set session_id if provided
  149. if session_id:
  150. span.set_data(SPANDATA.MCP_SESSION_ID, session_id)
  151. # Set request arguments (excluding common request context objects)
  152. for k, v in arguments.items():
  153. span.set_data(f"mcp.request.argument.{k}", safe_serialize(v))
  154. def _extract_tool_result_content(result: "Any") -> "Any":
  155. """
  156. Extract meaningful content from MCP tool result.
  157. Tool handlers can return:
  158. - tuple (UnstructuredContent, StructuredContent): Return the structured content (dict)
  159. - dict (StructuredContent): Return as-is
  160. - Iterable (UnstructuredContent): Extract text from content blocks
  161. """
  162. if result is None:
  163. return None
  164. # Handle CombinationContent: tuple of (UnstructuredContent, StructuredContent)
  165. if isinstance(result, tuple) and len(result) == 2:
  166. # Return the structured content (2nd element)
  167. return result[1]
  168. # Handle StructuredContent: dict
  169. if isinstance(result, dict):
  170. return result
  171. # Handle UnstructuredContent: iterable of ContentBlock objects
  172. # Try to extract text content
  173. if hasattr(result, "__iter__") and not isinstance(result, (str, bytes, dict)):
  174. texts = []
  175. try:
  176. for item in result:
  177. # Try to get text attribute from ContentBlock objects
  178. if hasattr(item, "text"):
  179. texts.append(item.text)
  180. elif isinstance(item, dict) and "text" in item:
  181. texts.append(item["text"])
  182. except Exception:
  183. # If extraction fails, return the original
  184. return result
  185. return " ".join(texts) if texts else result
  186. return result
  187. def _set_span_output_data(
  188. span: "Any", result: "Any", result_data_key: "Optional[str]", handler_type: str
  189. ) -> None:
  190. """Set output span data for MCP handlers."""
  191. if result is None:
  192. return
  193. # Get integration to check PII settings
  194. integration = sentry_sdk.get_client().get_integration(MCPIntegration)
  195. if integration is None:
  196. return
  197. # Check if we should include sensitive data
  198. should_include_data = should_send_default_pii() and integration.include_prompts
  199. # For tools, extract the meaningful content
  200. if handler_type == "tool":
  201. extracted = _extract_tool_result_content(result)
  202. if extracted is not None and should_include_data:
  203. span.set_data(result_data_key, safe_serialize(extracted))
  204. # Set content count if result is a dict
  205. if isinstance(extracted, dict):
  206. span.set_data(SPANDATA.MCP_TOOL_RESULT_CONTENT_COUNT, len(extracted))
  207. elif handler_type == "prompt":
  208. # For prompts, count messages and set role/content only for single-message prompts
  209. try:
  210. messages: "Optional[list[str]]" = None
  211. message_count = 0
  212. # Check if result has messages attribute (GetPromptResult)
  213. if hasattr(result, "messages") and result.messages:
  214. messages = result.messages
  215. message_count = len(messages)
  216. # Also check if result is a dict with messages
  217. elif isinstance(result, dict) and result.get("messages"):
  218. messages = result["messages"]
  219. message_count = len(messages)
  220. # Always set message count if we found messages
  221. if message_count > 0:
  222. span.set_data(SPANDATA.MCP_PROMPT_RESULT_MESSAGE_COUNT, message_count)
  223. # Only set role and content for single-message prompts if PII is allowed
  224. if message_count == 1 and should_include_data and messages:
  225. first_message = messages[0]
  226. # Extract role
  227. role = None
  228. if hasattr(first_message, "role"):
  229. role = first_message.role
  230. elif isinstance(first_message, dict) and "role" in first_message:
  231. role = first_message["role"]
  232. if role:
  233. span.set_data(SPANDATA.MCP_PROMPT_RESULT_MESSAGE_ROLE, role)
  234. # Extract content text
  235. content_text = None
  236. if hasattr(first_message, "content"):
  237. msg_content = first_message.content
  238. # Content can be a TextContent object or similar
  239. if hasattr(msg_content, "text"):
  240. content_text = msg_content.text
  241. elif isinstance(msg_content, dict) and "text" in msg_content:
  242. content_text = msg_content["text"]
  243. elif isinstance(msg_content, str):
  244. content_text = msg_content
  245. elif isinstance(first_message, dict) and "content" in first_message:
  246. msg_content = first_message["content"]
  247. if isinstance(msg_content, dict) and "text" in msg_content:
  248. content_text = msg_content["text"]
  249. elif isinstance(msg_content, str):
  250. content_text = msg_content
  251. if content_text:
  252. span.set_data(result_data_key, content_text)
  253. except Exception:
  254. # Silently ignore if we can't extract message info
  255. pass
  256. # Resources don't capture result content (result_data_key is None)
  257. # Handler data preparation and wrapping
  258. def _prepare_handler_data(
  259. handler_type: str,
  260. original_args: "tuple[Any, ...]",
  261. original_kwargs: "Optional[dict[str, Any]]" = None,
  262. ) -> "tuple[str, dict[str, Any], str, str, str, Optional[str]]":
  263. """
  264. Prepare common handler data for both async and sync wrappers.
  265. Returns:
  266. Tuple of (handler_name, arguments, span_data_key, span_name, mcp_method_name, result_data_key)
  267. """
  268. original_kwargs = original_kwargs or {}
  269. # Extract handler-specific data based on handler type
  270. if handler_type == "tool":
  271. if original_args:
  272. handler_name = original_args[0]
  273. elif original_kwargs.get("name"):
  274. handler_name = original_kwargs["name"]
  275. arguments = {}
  276. if len(original_args) > 1:
  277. arguments = original_args[1]
  278. elif original_kwargs.get("arguments"):
  279. arguments = original_kwargs["arguments"]
  280. elif handler_type == "prompt":
  281. if original_args:
  282. handler_name = original_args[0]
  283. elif original_kwargs.get("name"):
  284. handler_name = original_kwargs["name"]
  285. arguments = {}
  286. if len(original_args) > 1:
  287. arguments = original_args[1]
  288. elif original_kwargs.get("arguments"):
  289. arguments = original_kwargs["arguments"]
  290. # Include name in arguments dict for span data
  291. arguments = {"name": handler_name, **(arguments or {})}
  292. else: # resource
  293. handler_name = "unknown"
  294. if original_args:
  295. handler_name = str(original_args[0])
  296. elif original_kwargs.get("uri"):
  297. handler_name = str(original_kwargs["uri"])
  298. arguments = {}
  299. # Get span configuration
  300. span_data_key, span_name, mcp_method_name, result_data_key = _get_span_config(
  301. handler_type, handler_name
  302. )
  303. return (
  304. handler_name,
  305. arguments,
  306. span_data_key,
  307. span_name,
  308. mcp_method_name,
  309. result_data_key,
  310. )
  311. async def _handler_wrapper(
  312. handler_type: str,
  313. func: "Callable[..., Any]",
  314. original_args: "tuple[Any, ...]",
  315. original_kwargs: "Optional[dict[str, Any]]" = None,
  316. self: "Optional[Any]" = None,
  317. force_await: bool = True,
  318. ) -> "Any":
  319. """
  320. Wrapper for MCP handlers.
  321. Args:
  322. handler_type: "tool", "prompt", or "resource"
  323. func: The handler function to wrap
  324. original_args: Original arguments passed to the handler
  325. original_kwargs: Original keyword arguments passed to the handler
  326. self: Optional instance for bound methods
  327. """
  328. if original_kwargs is None:
  329. original_kwargs = {}
  330. (
  331. handler_name,
  332. arguments,
  333. span_data_key,
  334. span_name,
  335. mcp_method_name,
  336. result_data_key,
  337. ) = _prepare_handler_data(handler_type, original_args, original_kwargs)
  338. scopes = _get_active_http_scopes()
  339. isolation_scope_context: "ContextManager[Any]"
  340. current_scope_context: "ContextManager[Any]"
  341. if scopes is None:
  342. isolation_scope_context = nullcontext()
  343. current_scope_context = nullcontext()
  344. else:
  345. isolation_scope, current_scope = scopes
  346. isolation_scope_context = (
  347. nullcontext()
  348. if isolation_scope is None
  349. else sentry_sdk.scope.use_isolation_scope(isolation_scope)
  350. )
  351. current_scope_context = (
  352. nullcontext()
  353. if current_scope is None
  354. else sentry_sdk.scope.use_scope(current_scope)
  355. )
  356. # Get request ID, session ID, and transport from context
  357. request_id, session_id, mcp_transport = _get_request_context_data()
  358. # Start span and execute
  359. with isolation_scope_context:
  360. with current_scope_context:
  361. with get_start_span_function()(
  362. op=OP.MCP_SERVER,
  363. name=span_name,
  364. origin=MCPIntegration.origin,
  365. ) as span:
  366. # Set input span data
  367. _set_span_input_data(
  368. span,
  369. handler_name,
  370. span_data_key,
  371. mcp_method_name,
  372. arguments,
  373. request_id,
  374. session_id,
  375. mcp_transport,
  376. )
  377. # For resources, extract and set protocol
  378. if handler_type == "resource":
  379. if original_args:
  380. uri = original_args[0]
  381. else:
  382. uri = original_kwargs.get("uri")
  383. protocol = None
  384. if hasattr(uri, "scheme"):
  385. protocol = uri.scheme
  386. elif handler_name and "://" in handler_name:
  387. protocol = handler_name.split("://")[0]
  388. if protocol:
  389. span.set_data(SPANDATA.MCP_RESOURCE_PROTOCOL, protocol)
  390. try:
  391. # Execute the async handler
  392. if self is not None:
  393. original_args = (self, *original_args)
  394. result = func(*original_args, **original_kwargs)
  395. if force_await or inspect.isawaitable(result):
  396. result = await result
  397. except Exception as e:
  398. # Set error flag for tools
  399. if handler_type == "tool":
  400. span.set_data(SPANDATA.MCP_TOOL_RESULT_IS_ERROR, True)
  401. sentry_sdk.capture_exception(e)
  402. raise
  403. _set_span_output_data(span, result, result_data_key, handler_type)
  404. return result
  405. def _create_instrumented_decorator(
  406. original_decorator: "Callable[..., Any]",
  407. handler_type: str,
  408. *decorator_args: "Any",
  409. **decorator_kwargs: "Any",
  410. ) -> "Callable[..., Any]":
  411. """
  412. Create an instrumented version of an MCP decorator.
  413. This function intercepts MCP decorators (like @server.call_tool()) and injects
  414. Sentry instrumentation into the handler registration flow. The returned decorator
  415. will:
  416. 1. Receive the user's handler function
  417. 2. Pass the instrumented version to the original MCP decorator
  418. This ensures that when the handler is called at runtime, it's already wrapped
  419. with Sentry spans and metrics collection.
  420. Args:
  421. original_decorator: The original MCP decorator method (e.g., Server.call_tool)
  422. handler_type: "tool", "prompt", or "resource" - determines span configuration
  423. decorator_args: Positional arguments to pass to the original decorator (e.g., self)
  424. decorator_kwargs: Keyword arguments to pass to the original decorator
  425. Returns:
  426. A decorator function that instruments handlers before registering them
  427. """
  428. def instrumented_decorator(func: "Callable[..., Any]") -> "Callable[..., Any]":
  429. @wraps(func)
  430. async def wrapper(*args: "Any") -> "Any":
  431. return await _handler_wrapper(handler_type, func, args, force_await=False)
  432. # Then register it with the original MCP decorator
  433. return original_decorator(*decorator_args, **decorator_kwargs)(wrapper)
  434. return instrumented_decorator
  435. def _patch_lowlevel_server() -> None:
  436. """
  437. Patches the mcp.server.lowlevel.Server class to instrument handler execution.
  438. """
  439. # Patch call_tool decorator
  440. original_call_tool = Server.call_tool
  441. def patched_call_tool(
  442. self: "Server", **kwargs: "Any"
  443. ) -> "Callable[[Callable[..., Any]], Callable[..., Any]]":
  444. """Patched version of Server.call_tool that adds Sentry instrumentation."""
  445. return lambda func: _create_instrumented_decorator(
  446. original_call_tool, "tool", self, **kwargs
  447. )(func)
  448. Server.call_tool = patched_call_tool
  449. # Patch get_prompt decorator
  450. original_get_prompt = Server.get_prompt
  451. def patched_get_prompt(
  452. self: "Server",
  453. ) -> "Callable[[Callable[..., Any]], Callable[..., Any]]":
  454. """Patched version of Server.get_prompt that adds Sentry instrumentation."""
  455. return lambda func: _create_instrumented_decorator(
  456. original_get_prompt, "prompt", self
  457. )(func)
  458. Server.get_prompt = patched_get_prompt
  459. # Patch read_resource decorator
  460. original_read_resource = Server.read_resource
  461. def patched_read_resource(
  462. self: "Server",
  463. ) -> "Callable[[Callable[..., Any]], Callable[..., Any]]":
  464. """Patched version of Server.read_resource that adds Sentry instrumentation."""
  465. return lambda func: _create_instrumented_decorator(
  466. original_read_resource, "resource", self
  467. )(func)
  468. Server.read_resource = patched_read_resource
  469. def _patch_handle_request() -> None:
  470. original_handle_request = StreamableHTTPServerTransport.handle_request
  471. @wraps(original_handle_request)
  472. async def patched_handle_request(
  473. self: "StreamableHTTPServerTransport",
  474. scope: "Scope",
  475. receive: "Receive",
  476. send: "Send",
  477. ) -> None:
  478. scope.setdefault("state", {})["sentry_sdk.isolation_scope"] = (
  479. sentry_sdk.get_isolation_scope()
  480. )
  481. scope["state"]["sentry_sdk.current_scope"] = sentry_sdk.get_current_scope()
  482. await original_handle_request(self, scope, receive, send)
  483. StreamableHTTPServerTransport.handle_request = patched_handle_request
  484. def _patch_fastmcp() -> None:
  485. """
  486. Patches the standalone fastmcp package's FastMCP class.
  487. The standalone fastmcp package (v2.14.0+) registers its own handlers for
  488. prompts and resources directly, bypassing the Server decorators we patch.
  489. This function patches the _get_prompt_mcp and _read_resource_mcp methods
  490. to add instrumentation for those handlers.
  491. """
  492. if hasattr(FastMCP, "_get_prompt_mcp"):
  493. original_get_prompt_mcp = FastMCP._get_prompt_mcp
  494. @wraps(original_get_prompt_mcp)
  495. async def patched_get_prompt_mcp(
  496. self: "Any", *args: "Any", **kwargs: "Any"
  497. ) -> "Any":
  498. return await _handler_wrapper(
  499. "prompt",
  500. original_get_prompt_mcp,
  501. args,
  502. kwargs,
  503. self,
  504. )
  505. FastMCP._get_prompt_mcp = patched_get_prompt_mcp
  506. if hasattr(FastMCP, "_read_resource_mcp"):
  507. original_read_resource_mcp = FastMCP._read_resource_mcp
  508. @wraps(original_read_resource_mcp)
  509. async def patched_read_resource_mcp(
  510. self: "Any", *args: "Any", **kwargs: "Any"
  511. ) -> "Any":
  512. return await _handler_wrapper(
  513. "resource",
  514. original_read_resource_mcp,
  515. args,
  516. kwargs,
  517. self,
  518. )
  519. FastMCP._read_resource_mcp = patched_read_resource_mcp