traceloop / traceloop/openllmetry
๐ Bug Report: MCP client instrumentation never propagates trace context for ordinary tool calls (regression from #3179)
Nobody has claimed this yet.
- Dominant language
- Python
- Stars
- 7.4k
- Forks
- 1.1k
- Avg merge
- 8d 14h
- Merged PRs (30d)
- 2
Description
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
- Run an MCP client and server both instrumented via
Traceloop.init()(which enablesInstruments.MCPby default), talking over the Streamable HTTP transport. - From within a traced request/span, call any tool via the standard
ClientSession.call_tool(...)(or alangchain_mcp_adapters-generated tool wrapper) โ i.e. a call that does not pass aprogress_callback. - Inspect the outgoing JSON-RPC request body:
params._metais absent. - On the server, the resulting
tools/callspan 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:
- Go to
.envand updateMCP_ENABLED=True. - Run
make start-agentic - Navigate to
localhost:8080/chatbot - Click on one of the suggested questions
- Navigate to Jaeger
localhost:8080/jaeger - Check that
agentandmcphave 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!
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up โ it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start at McpInstrumentor.patch_mcp_client(), which wraps mcp.shared.session.BaseSession.send_request, and inspect how RequestParams.meta is handled before propagation. Reproduce with an ordinary ClientSession.call_tool() call without a progress_callback, then verify that the outgoing params._meta carries traceparent and that the client and server spans share a trace.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- python
- Domain
- observability
- Issue type
- Bug
- Difficulty
- 3/5
- Estimated time
- 1-2 days
- Activity status
- Quiet
- Clarity
- Clearly specified
- Newbie friendliness
- 68/100