diff --git a/packages/opentelemetry-instrumentation-mcp/opentelemetry/instrumentation/mcp/instrumentation.py b/packages/opentelemetry-instrumentation-mcp/opentelemetry/instrumentation/mcp/instrumentation.py index 688cdb2c7f..118c8d0993 100644 --- a/packages/opentelemetry-instrumentation-mcp/opentelemetry/instrumentation/mcp/instrumentation.py +++ b/packages/opentelemetry-instrumentation-mcp/opentelemetry/instrumentation/mcp/instrumentation.py @@ -187,16 +187,20 @@ async def traced_method(wrapped, instance, args, kwargs): method = args[0].root.method if len(args) > 0 and hasattr(args[0].root, "params"): params = args[0].root.params - if params: - if hasattr(args[0].root.params, "meta"): - meta = args[0].root.params.meta + if params is not None and hasattr(params, "meta"): + meta = params.meta + if meta is None: + from mcp.types import RequestParams - # Handle trace context propagation - if meta and len(args) > 0: + meta = RequestParams.Meta() + + # Handle trace context propagation, including requests without _meta. + if meta is not None: carrier = {} TraceContextTextMapPropagator().inject(carrier) - meta.traceparent = carrier["traceparent"] - args[0].root.params.meta = meta + for key, value in carrier.items(): + setattr(meta, key, value) + params.meta = meta # Create different span types based on method if method == "tools/call": diff --git a/packages/opentelemetry-instrumentation-mcp/tests/test_client_trace_context.py b/packages/opentelemetry-instrumentation-mcp/tests/test_client_trace_context.py new file mode 100644 index 0000000000..4ed05bae57 --- /dev/null +++ b/packages/opentelemetry-instrumentation-mcp/tests/test_client_trace_context.py @@ -0,0 +1,42 @@ +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest +from mcp.types import CallToolRequestParams, RequestParams +from opentelemetry.instrumentation.mcp import instrumentation +from opentelemetry.instrumentation.mcp.instrumentation import McpInstrumentor + + +@pytest.mark.parametrize( + ("meta", "expected_custom_value"), + [ + (None, None), + (RequestParams.Meta(custom_value="preserved"), "preserved"), + ], +) +async def test_client_trace_context_is_injected_without_replacing_metadata(monkeypatch, meta, expected_custom_value): + """Verify ordinary tool calls propagate trace context and preserve metadata.""" + propagator = AsyncMock() + propagator.inject = lambda carrier: carrier.update( + { + "traceparent": "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01", + "tracestate": "vendor=value", + } + ) + monkeypatch.setattr(instrumentation, "TraceContextTextMapPropagator", lambda: propagator) + + # BaseInstrumentor is a singleton. Bypass it so this unit test cannot mutate + # the session-scoped instrumentor used by the integration tests. + instrumentor = object.__new__(McpInstrumentor) + instrumentor._handle_tool_call = AsyncMock(return_value="sent") + params = CallToolRequestParams(name="test", arguments={}, _meta=meta) + message = SimpleNamespace(root=SimpleNamespace(method="tools/call", params=params)) + + wrapped = AsyncMock() + result = await instrumentor.patch_mcp_client(AsyncMock())(wrapped, None, (message,), {}) + + assert result == "sent" + assert params.meta is not None + assert params.meta.traceparent == "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01" + assert params.meta.tracestate == "vendor=value" + assert getattr(params.meta, "custom_value", None) == expected_custom_value