Skip to content

MCP tool calls break distributed tracing within same service #5272

@maxclaus

Description

@maxclaus

Problem Statement

MCP tool calls do not continue distributed traces from their parent HTTP requests within the same service. The MCP integration creates new transactions with new trace IDs instead of joining the existing trace from the incoming HTTP request.

Environment:

  • sentry-python version: 2.48.0
  • MCP server: FastMCP (HTTP/SSE transport) within FastAPI application
  • Service architecture:
    • Service A (Go + Sentry) → Service B (Python/FastAPI + Sentry + MCP Server)
    • HTTP trace propagates correctly to Service B's FastAPI handler
    • MCP tool execution in Service B breaks the trace chain

Current behavior:
When get_start_span_function() executes during MCP tool handler wrapping, the HTTP transaction isn't accessible due to async context isolation. The function checks the current scope, but only finds unrelated spans (e.g., Redis, database operations) with different trace IDs, not the HTTP transaction that has the correct trace context from the incoming request.

Expected behavior:
MCP tool transaction should continue the HTTP request's trace (same trace_id, becomes child of HTTP span).

Actual behavior:
New transaction created with new trace_id from unrelated span in scope (e.g., Redis connection span), breaking the trace chain.

Root cause:
Async context isolation prevents the HTTP transaction from being accessible in the MCP tool handler's execution context.

Solution Brainstorm

Current workaround

I patched sentry-sdk to get it working for my project:

"""
Fix for MCP integration to properly continue distributed traces as transactions.

The issue: The MCP integration's get_start_span_function() doesn't check for
propagated trace context from incoming sentry-trace headers, so it creates new
transactions with new trace_ids instead of continuing the distributed trace.

The fix: When sentry-trace headers are present in the MCP request, use
continue_trace() to create a transaction that continues the existing trace
(same trace_id, but still a transaction for MCP Insights dashboard).

This maintains both:
1. Distributed tracing (same trace_id across services)
2. MCP Insights dashboard functionality (transactions, not spans)
"""
import logging
from typing import Any, Callable
import sentry_sdk
from sentry_sdk.tracing import Transaction

logger = logging.getLogger(__name__)


def create_transaction_from_trace_headers(
    op: str = None,
    name: str = None,
    origin: str = "manual",
    **kwargs: Any
) -> Transaction:
    """
    Creates a transaction that continues the distributed trace from MCP request headers.

    This uses continue_trace() which creates a Transaction with the trace_id
    and parent_span_id from the sentry-trace header, so the transaction
    becomes a child of the distributed trace while remaining a transaction
    (not a span) for MCP Insights compatibility.
    """
    from mcp.server.lowlevel.server import request_ctx

    try:
        ctx = request_ctx.get()
        if ctx and hasattr(ctx, "request") and ctx.request is not None:
            request = ctx.request
            if hasattr(request, "headers"):
                headers = {}

                # Extract sentry-trace and baggage headers
                sentry_trace = request.headers.get("sentry-trace")
                baggage = request.headers.get("baggage")

                if sentry_trace:
                    headers["sentry-trace"] = sentry_trace

                if baggage:
                    headers["baggage"] = baggage

                if headers:
                    # Use continue_trace to create a transaction that continues the trace
                    # This maintains the same trace_id but creates a transaction (not a span)
                    isolation_scope = sentry_sdk.get_isolation_scope()
                    transaction = isolation_scope.continue_trace(
                        environ_or_headers=headers,
                        op=op,
                        name=name,
                        origin=origin
                    )

                    # Start the transaction on the scope so it becomes the active transaction
                    # This allows the MCP integration to set data on it
                    return sentry_sdk.start_transaction(transaction=transaction)
    except Exception:
        # Could not extract headers - fall through to creating new transaction
        pass

    # Fallback to regular transaction if headers not available
    return sentry_sdk.start_transaction(op=op, name=name, origin=origin, **kwargs)


def patched_get_start_span_function() -> Callable:
    """
    Patched version that checks for propagated trace in MCP request headers.

    Returns a function that will create either:
    - A transaction continuing the distributed trace (if sentry-trace header present)
    - A span (if there's a transaction in scope)
    - A new transaction (if neither of the above)
    """
    # First, check if we have sentry-trace headers in MCP request context
    has_trace_header = False
    try:
        from mcp.server.lowlevel.server import request_ctx

        ctx = request_ctx.get()
        if ctx and hasattr(ctx, "request") and ctx.request is not None:
            request = ctx.request
            if hasattr(request, "headers"):
                sentry_trace = request.headers.get("sentry-trace")
                if sentry_trace:
                    has_trace_header = True
    except Exception:
        # MCP not installed or no request context - fall through to normal logic
        pass

    # If we have trace headers, return our custom function that creates
    # a transaction continuing the trace
    if has_trace_header:
        return create_transaction_from_trace_headers

    # Otherwise, use original logic
    current_span = sentry_sdk.get_current_span()
    scope = sentry_sdk.get_current_scope()
    transaction = scope.transaction if scope else None

    if transaction:
        return sentry_sdk.start_span

    if current_span and current_span.containing_transaction:
        return sentry_sdk.start_span

    # No transaction or propagated trace found
    return sentry_sdk.start_transaction


# Apply the patch to both the utils module AND the mcp integration module
import sentry_sdk.ai.utils

sentry_sdk.ai.utils.get_start_span_function = patched_get_start_span_function

# Also patch it in the mcp module where it's already imported
import sentry_sdk.integrations.mcp

sentry_sdk.integrations.mcp.get_start_span_function = patched_get_start_span_function

logger.info("MCP integration patched: get_start_span_function creates transactions from trace headers")

Before this change:
Image

After this change:
Image

Proper Solution:
Modifie get_start_span_function() in sentry_sdk/ai/utils.py to check for sentry-trace headers in MCP request context before falling back to scope checking.
Where _create_transaction_from_mcp_headers extracts headers and uses continue_trace() to create a transaction inheriting the trace context.
For instance: #5271

Open questions:

  1. HTTP headers vs _meta field: This approach reads trace context from HTTP headers. feat(develop): Add distributed tracing for MCP sentry-docs#15752 documents using the _meta field in
    MCP protocol. Should both be supported?
  2. Transport compatibility: HTTP headers work for SSE/HTTP transports, while _meta would work for all transports (stdio, SSE, HTTP). What's preferred?
  3. Transactions vs Spans: This creates transactions (not spans) to maintain MCP Insights dashboard compatibility while continuing the distributed trace. Is this the right trade-off?

Metadata

Metadata

Assignees

No one assigned

    Projects

    Status

    Waiting for: Product Owner

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions