From fda8f7ef9a90a2ce8fd216e5f1e970b223d594d9 Mon Sep 17 00:00:00 2001 From: Patrick Ribbsaeter Date: Sun, 26 Jul 2026 22:31:59 +0200 Subject: [PATCH 1/3] fix: propagate MCP trace context without metadata --- .../instrumentation/mcp/instrumentation.py | 18 ++++---- .../tests/test_client_trace_context.py | 41 +++++++++++++++++++ 2 files changed, 52 insertions(+), 7 deletions(-) create mode 100644 packages/opentelemetry-instrumentation-mcp/tests/test_client_trace_context.py 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..56c4cc578a --- /dev/null +++ b/packages/opentelemetry-instrumentation-mcp/tests/test_client_trace_context.py @@ -0,0 +1,41 @@ +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): + 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_mcp_method = AsyncMock(return_value="sent") + params = CallToolRequestParams(name="test", arguments={}, _meta=meta) + message = SimpleNamespace(root=SimpleNamespace(method="test/request", 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 From 036727645328f4fafabb8ebb274a7e4753f81ebb Mon Sep 17 00:00:00 2001 From: Patrick Ribbsaeter Date: Sun, 26 Jul 2026 23:16:43 +0200 Subject: [PATCH 2/3] test: exercise MCP tool-call trace propagation --- .../tests/test_client_trace_context.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/opentelemetry-instrumentation-mcp/tests/test_client_trace_context.py b/packages/opentelemetry-instrumentation-mcp/tests/test_client_trace_context.py index 56c4cc578a..69fc6686d8 100644 --- a/packages/opentelemetry-instrumentation-mcp/tests/test_client_trace_context.py +++ b/packages/opentelemetry-instrumentation-mcp/tests/test_client_trace_context.py @@ -27,9 +27,9 @@ async def test_client_trace_context_is_injected_without_replacing_metadata(monke # 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_mcp_method = AsyncMock(return_value="sent") + instrumentor._handle_tool_call = AsyncMock(return_value="sent") params = CallToolRequestParams(name="test", arguments={}, _meta=meta) - message = SimpleNamespace(root=SimpleNamespace(method="test/request", params=params)) + message = SimpleNamespace(root=SimpleNamespace(method="tools/call", params=params)) wrapped = AsyncMock() result = await instrumentor.patch_mcp_client(AsyncMock())(wrapped, None, (message,), {}) From 5773bd86dccf5784920c6da64f43c3f1317d85f1 Mon Sep 17 00:00:00 2001 From: Patrick Ribbsaeter Date: Sun, 26 Jul 2026 23:29:05 +0200 Subject: [PATCH 3/3] test: document MCP trace propagation coverage --- .../tests/test_client_trace_context.py | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/opentelemetry-instrumentation-mcp/tests/test_client_trace_context.py b/packages/opentelemetry-instrumentation-mcp/tests/test_client_trace_context.py index 69fc6686d8..4ed05bae57 100644 --- a/packages/opentelemetry-instrumentation-mcp/tests/test_client_trace_context.py +++ b/packages/opentelemetry-instrumentation-mcp/tests/test_client_trace_context.py @@ -15,6 +15,7 @@ ], ) 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( {