Distributed Tracing for MCP Tool Calls with OpenTelemetry

13 min read Module 9 of 10 Topic 25 of 30

What you'll learn

  • Instrument tool calls as OpenTelemetry spans with meaningful attributes
  • Propagate trace context across a federation gateway or multi-server session so spans link into one trace
  • Use trace data to answer 'why was this agent task slow or wrong' after the fact
  • Correlate MCP-side traces with the agent's own reasoning steps to see the full picture, not just the tool-call portion
Building this at your company? For enterprise and company teams taking this to production: book a 30-minute session with our AI engineers for architecture guidance, code review, and a rollout plan for your use case.
Book a Team Session

A single agent task, “look up this customer’s account health”, can fan out into several tool calls across multiple MCP servers. Without distributed tracing, each server’s logs sit in isolation, making it nearly impossible to reconstruct where time went or which call actually caused a failure. OpenTelemetry, already the standard for tracing in most enterprise stacks, applies directly to MCP tool calls, and the full picture requires reaching beyond the MCP server itself into the agent’s own reasoning time.

Instrumenting Tool Calls as Spans

from opentelemetry import trace
from opentelemetry.trace import Status, StatusCode

tracer = trace.get_tracer("enterprise-mcp-server")

@mcp.middleware()
async def tracing_middleware(request, call_next):
    with tracer.start_as_current_span(
        f"mcp.tool_call.{request.tool_name}",
        attributes={
            "mcp.tool_name": request.tool_name,
            "mcp.caller_subject": getattr(request.state, "subject", "unknown"),
            "mcp.session_id": request.session_id,
            "mcp.arguments_summary": _summarize_args(request.arguments),
        },
    ) as span:
        try:
            result = await call_next(request)
            span.set_status(Status(StatusCode.OK))
            return result
        except Exception as e:
            span.set_status(Status(StatusCode.ERROR, str(e)))
            span.record_exception(e)
            raise

def _summarize_args(args: dict) -> str:
    """Avoid putting large or sensitive payloads directly in span attributes."""
    return ", ".join(f"{k}={str(v)[:50]}" for k, v in args.items())

Propagating Trace Context Across Servers

For a trace to connect calls across a federation gateway (Lesson 17) or a multi-server agent session (Lesson 24) into one coherent view, the trace context must travel with the request, standard W3C Trace Context propagation over HTTP headers handles this automatically once both client and server are instrumented.

from opentelemetry.propagate import inject

async def call_tool_with_trace_propagation(session, tool_name: str, arguments: dict):
    headers = {}
    inject(headers)  # Injects traceparent header from the current span context
    return await session.call_tool(tool_name, arguments, extra_headers=headers)
sequenceDiagram
    participant Agent
    participant Gateway as Federation Gateway
    participant CRM as CRM MCP Server
    participant KB as Knowledge-Base MCP Server

    Agent->>Gateway: tools/call crm.search_accounts\n(traceparent: trace-id-A)
    Gateway->>CRM: tools/call search_accounts\n(traceparent: trace-id-A, propagated)
    CRM-->>Gateway: result (span: crm.search_accounts, 340ms)
    Gateway-->>Agent: result
    Agent->>Gateway: tools/call kb.query\n(traceparent: trace-id-A)
    Gateway->>KB: tools/call query\n(traceparent: trace-id-A, propagated)
    KB-->>Gateway: result (span: kb.query, 2100ms)
    Gateway-->>Agent: result

    Note over Agent,KB: One trace (trace-id-A) links every span,\nrevealing kb.query as the slow step

Closing the Gap: Correlating with the Agent’s Own Reasoning Time

Tracing only the MCP server side answers “how long did each tool call take,” but a real agent task’s total wall-clock time also includes model inference latency and reasoning time between tool calls, deciding what to call next, interpreting a result, formulating the next query, none of which is visible from MCP-side spans alone. If a trace shows tool calls accounting for only a fraction of the total task time, the remainder is almost always sitting in this client-side reasoning gap, and finding out for certain requires the agent framework itself to also emit spans into the same trace, not just the server.

# Client/agent side: wrap each model inference call and each reasoning
# step in its own span, propagated into the SAME trace as the MCP spans,
# so the full task timeline, not just the tool-call portion, is visible.
async def run_agent_turn_with_tracing(model_client, messages, tools, tracer):
    with tracer.start_as_current_span("agent.model_inference") as span:
        response = await model_client.chat.completions.create(model=MODEL, messages=messages, tools=tools)
        span.set_attribute("agent.tokens_used", response.usage.total_tokens)

    msg = response.choices[0].message
    if msg.tool_calls:
        with tracer.start_as_current_span("agent.tool_call_dispatch"):
            # Each dispatched call's traceparent is injected here (as in the
            # earlier snippet), linking the MCP-side spans into this same trace.
            results = await asyncio.gather(*(execute_single_call(mcp_session, c) for c in msg.tool_calls))
        messages.extend(results)
        return await run_agent_turn_with_tracing(model_client, messages, tools, tracer)
    return msg.content
flowchart LR
    subgraph fullTrace["One trace, full picture"]
        Infer1["agent.model_inference\n1.2s"] --> Dispatch1["agent.tool_call_dispatch"]
        Dispatch1 --> CRMSpan["mcp.tool_call.crm.search_accounts\n0.34s"]
        Dispatch1 --> KBSpan["mcp.tool_call.kb.query\n2.1s"]
        CRMSpan --> Infer2["agent.model_inference\n0.9s"]
        KBSpan --> Infer2
        Infer2 --> Final["Final response"]
    end

    style Infer1 fill:#EEF0F7,stroke:#6366F1,color:#0F172A
    style Infer2 fill:#EEF0F7,stroke:#6366F1,color:#0F172A
    style Dispatch1 fill:#fff7ed,stroke:#f59e0b,color:#0F172A
    style CRMSpan fill:#f0fdf9,stroke:#0D9488,color:#0F172A
    style KBSpan fill:#f0fdf9,stroke:#0D9488,color:#0F172A
    style Final fill:#fff7ed,stroke:#f59e0b,color:#0F172A

With both sides instrumented into the same trace, the earlier 8-second task now fully accounts for itself: roughly 2.1 seconds of tool execution, and the remaining time split across two model inference calls, a genuinely actionable breakdown rather than a mystery gap.

Using Traces to Debug Agent Task Performance, and Alerting on Them

Once traces flow into a backend (Jaeger, Tempo, or a hosted APM), a slow or failed agent task can be inspected end to end. Beyond ad hoc investigation, the same span attributes support standing alerts: a p95 latency threshold on mcp.tool_call.* spans grouped by tool name catches a specific tool degrading before users notice broadly, and an error-rate threshold on the same grouping feeds directly into the canary rollback analysis built in Lesson 29, the tracing infrastructure built here is not only for reactive debugging, it is the data source several of this course’s other production controls depend on.

The next lesson complements tracing (which observes production behavior) with contract testing, which catches tool regressions before they ever reach production traffic.

Knowledge Check

3 questions to test your understanding

1 An agent task calls three tools across two different MCP servers, and the overall task took 8 seconds longer than expected. Without distributed tracing, why is this hard to diagnose?

2 What attribute is most useful to attach to a tool-call span for later debugging tool-selection issues, beyond duration and success/failure?

3 A trace shows the MCP-side spans (three tool calls) accounting for only 3 of the task's total 8 seconds. Where does the remaining 5 seconds most likely live, and how would a team find out?

Go further with expert guidance

Ready to build production AI?
Talk to our R&D team.

These courses give you the foundation. Our embedded AI teams take you from prototype to production in 30–90 days, with your team, your codebase, your goals. Book a free strategy call to see how we can accelerate your AI initiative.

30 minutes · No obligation · Expert AI engineers, not sales reps

AI Architecture Review

Audit your current stack and identify high-impact improvements

Project Review

Get expert feedback on your AI implementation and codebase

Team Mentoring

Upskill your engineers with hands-on AI coaching sessions

AI Strategy

Define your AI roadmap, prioritization, and implementation plan