Skip to content

🐛 Bug Report: MCP client instrumentation never propagates trace context for ordinary tool calls (regression from #3179) #4374

Description

@julianocosta89

Which component is this bug for?

Traceloop SDK

📜 Description

McpInstrumentor.patch_mcp_client() (which wraps mcp.shared.session.BaseSession.send_request) is supposed to inject a traceparent into the outgoing JSON-RPC request's _meta field so that the MCP server can continue the client's trace. In practice this never happens for a normal tool call, because the injection is gated behind a check that only runs when params.meta is already non-None:

if params:
    if hasattr(args[0].root.params, "meta"):
        meta = args[0].root.params.meta

if meta and len(args) > 0:
    carrier = {}
    TraceContextTextMapPropagator().inject(carrier)
    meta.traceparent = carrier["traceparent"]
    args[0].root.params.meta = meta

RequestParams.meta (aliased to _meta) defaults to None and is only populated by the mcp SDK itself when a caller explicitly requests a progress token (progress_callback argument). Nothing in the standard mcp.ClientSession.call_tool() path, nor langchain_mcp_adapters.tools.load_mcp_tools(), sets a progress token by default. So for an ordinary tool call, params.meta is None, if meta: is False, and the traceparent is silently never attached. The MCP server therefore starts a brand new trace instead of continuing the caller's trace.

This is a regression introduced by #3179 ("fix(mcp): do not override meta pydantic types"). Before that PR, the code always created a Meta() object when one was missing:

if params is None:
    args[0].root.params = mcp.types.RequestParams()
    meta = mcp.types.RequestParams.Meta()
else:
    if hasattr(args[0].root.params, "meta"):
        meta = args[0].root.params.meta
    if meta is None:
        meta = mcp.types.RequestParams.Meta()
...
meta.traceparent = parent_span["traceparent"]
args[0].root.params.meta = meta

#3179's stated goal was to stop clobbering an already-present _meta (e.g. when a progress token was set) — but the fix went further than needed and removed the "create an empty Meta() when absent" fallback entirely, instead of only skipping the overwrite case. That collapsed the common case (no pre-existing _meta) into a no-op.

👟 Reproduction steps

  1. Run an MCP client and server both instrumented via Traceloop.init() (which enables Instruments.MCP by default), talking over the Streamable HTTP transport.
  2. From within a traced request/span, call any tool via the standard ClientSession.call_tool(...) (or a langchain_mcp_adapters-generated tool wrapper) — i.e. a call that does not pass a progress_callback.
  3. Inspect the outgoing JSON-RPC request body: params._meta is absent.
  4. On the server, the resulting tools/call span has no parent — it starts a new trace, instead of continuing the trace of the request that triggered the tool call.

Reproduced against traceloop-sdk==0.62.1 / opentelemetry-instrumentation-mcp==0.62.1 (current latest), mcp==1.28.1, fastmcp==3.4.4. Confirmed via two traces from a real deployment: the caller's trace ends at a {tool_name}.tool span with no HTTP/transport child span, and the MCP server's trace root is tools/call {tool_name} with no parent context.

This can also be reproduced in the OpenTelemetry Demo:

  1. Go to .env and update MCP_ENABLED=True.
  2. Run make start-agentic
  3. Navigate to localhost:8080/chatbot
  4. Click on one of the suggested questions
  5. Navigate to Jaeger localhost:8080/jaeger
  6. Check that agent and mcp have 2 disconnected traces.

👍 Expected behavior

traceparent (and any other configured propagator fields) should be injected into params._meta for every tool call made while a span is active, regardless of whether _meta was already present — merging into existing _meta rather than skipping injection when it's absent.

👎 Actual Behavior with Screenshots

For any tool call that doesn't already carry a _meta object (the default case), no trace context is attached to the outgoing request, and the client/server traces are permanently disconnected.

🤖 Python Version

3.14

📃 Provide any additional context for the Bug.

Suggested fix: create an empty Meta()/{} when meta is None (restoring the pre-#3179 fallback) and only avoid overwriting an existing traceparent/_meta value that's already set, rather than skipping the whole block. Something like:

if params:
    meta = getattr(args[0].root.params, "meta", None)
if meta is None and params is not None:
    meta = mcp.types.RequestParams.Meta()
    args[0].root.params.meta = meta

if meta is not None and len(args) > 0:
    carrier = {}
    TraceContextTextMapPropagator().inject(carrier)
    meta.traceparent = carrier["traceparent"]

Happy to open a PR with this fix if maintainers confirm the approach.

👀 Have you spent some time to check if this bug has been raised before?

  • I checked and didn't find similar issue

Are you willing to submit PR?

Yes I am willing to submit a PR!

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions