mcp.py 22 KB

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