From 456371b1b21e8327ee703e5f67c175f20fb02fe4 Mon Sep 17 00:00:00 2001 From: Pete Roden Date: Tue, 10 Feb 2026 01:43:14 +0000 Subject: [PATCH 1/4] feat: Inject OpenTelemetry trace context into MCP requests and update documentation --- python/packages/core/agent_framework/_mcp.py | 26 +++++++- python/packages/core/tests/core/test_mcp.py | 64 +++++++++++++++++++ .../getting_started/observability/README.md | 4 ++ 3 files changed, 93 insertions(+), 1 deletion(-) diff --git a/python/packages/core/agent_framework/_mcp.py b/python/packages/core/agent_framework/_mcp.py index 578fb606e1..dd189bf6d3 100644 --- a/python/packages/core/agent_framework/_mcp.py +++ b/python/packages/core/agent_framework/_mcp.py @@ -300,6 +300,27 @@ def _normalize_mcp_name(name: str) -> str: return re.sub(r"[^A-Za-z0-9_.-]", "-", name) +def _inject_otel_into_mcp_meta(meta: dict[str, Any] | None = None) -> dict[str, Any] | None: + """Inject OpenTelemetry trace context into MCP request _meta via the global propagator(s).""" + try: + from opentelemetry import propagate + except ImportError: # pragma: no cover + return meta + + carrier: dict[str, str] = {} + propagate.inject(carrier) + if not carrier: + return meta + + if meta is None: + meta = {} + for key, value in carrier.items(): + if key not in meta: + meta[key] = value + + return meta + + # region: MCP Plugin @@ -763,10 +784,13 @@ async def call_tool(self, tool_name: str, **kwargs: Any) -> list[Content] | Any not in {"chat_options", "tools", "tool_choice", "thread", "conversation_id", "options", "response_format"} } + # Inject OpenTelemetry trace context into MCP _meta for distributed tracing. + otel_meta = _inject_otel_into_mcp_meta() + # Try the operation, reconnecting once if the connection is closed for attempt in range(2): try: - result = await self.session.call_tool(tool_name, arguments=filtered_kwargs) # type: ignore + result = await self.session.call_tool(tool_name, arguments=filtered_kwargs, meta=otel_meta) # type: ignore if self.parse_tool_results is None: return result if self.parse_tool_results is True: diff --git a/python/packages/core/tests/core/test_mcp.py b/python/packages/core/tests/core/test_mcp.py index 7695affb5a..24b4cd47be 100644 --- a/python/packages/core/tests/core/test_mcp.py +++ b/python/packages/core/tests/core/test_mcp.py @@ -2707,3 +2707,67 @@ class MockResponseFormat(BaseModel): assert "thread" not in arguments assert "conversation_id" not in arguments assert "options" not in arguments + + +# region: OTel trace context propagation via _meta + + +@pytest.mark.parametrize( + "use_span,expect_traceparent", + [ + (True, True), + (False, False), + ], +) +async def test_mcp_tool_call_tool_otel_meta(use_span, expect_traceparent, span_exporter): + """call_tool propagates OTel trace context via meta only when a span is active.""" + from opentelemetry import trace + + class TestServer(MCPTool): + async def connect(self): + self.session = Mock(spec=ClientSession) + self.session.list_tools = AsyncMock( + return_value=types.ListToolsResult( + tools=[ + types.Tool( + name="test_tool", + description="Test tool", + inputSchema={ + "type": "object", + "properties": {"param": {"type": "string"}}, + "required": ["param"], + }, + ) + ] + ) + ) + self.session.call_tool = AsyncMock( + return_value=types.CallToolResult(content=[types.TextContent(type="text", text="result")]) + ) + + def get_mcp_client(self) -> _AsyncGeneratorContextManager[Any, None]: + return None + + server = TestServer(name="test_server") + async with server: + await server.load_tools() + + if use_span: + tracer = trace.get_tracer("test") + with tracer.start_as_current_span("test_span"): + await server.functions[0].invoke(param="test_value") + else: + # Use an invalid span to ensure no trace context is injected; + # call server.call_tool directly to bypass FunctionTool.invoke's own span. + with trace.use_span(trace.NonRecordingSpan(trace.INVALID_SPAN_CONTEXT)): + await server.call_tool("test_tool", param="test_value") + + meta = server.session.call_tool.call_args.kwargs.get("meta") + if expect_traceparent: + assert meta is not None + assert "traceparent" in meta + else: + assert meta is None + + +# endregion diff --git a/python/samples/getting_started/observability/README.md b/python/samples/getting_started/observability/README.md index 5f3dd238c2..ea496f94f7 100644 --- a/python/samples/getting_started/observability/README.md +++ b/python/samples/getting_started/observability/README.md @@ -22,6 +22,10 @@ The Agent Framework Python SDK is designed to efficiently generate comprehensive Next to what happens in the code when you run, we also make setting up observability as easy as possible. By calling a single function `configure_otel_providers()` from the `agent_framework.observability` module, you can enable telemetry for traces, logs, and metrics. The function automatically reads standard OpenTelemetry environment variables to configure exporters and providers, making it simple to get started. +### MCP trace propagation + +When observability is enabled, Agent Framework automatically propagates trace context to MCP servers via the `params._meta` field of `tools/call` requests. It uses the globally-configured OpenTelemetry propagator(s) (W3C Trace Context by default, producing `traceparent` and `tracestate`), so custom propagators (B3, Jaeger, etc.) are also supported. This enables distributed tracing across agent-to-MCP-server boundaries for all transports (stdio, HTTP, WebSocket), compliant with the [MCP `_meta` specification](https://modelcontextprotocol.io/specification/2025-06-18/basic#_meta). + ### Five patterns for configuring observability We've identified multiple ways to configure observability in your application, depending on your needs: From 472a0562fd16dc0b7c48e587396ea7ac96fb32c0 Mon Sep 17 00:00:00 2001 From: Pete Roden Date: Tue, 10 Feb 2026 09:07:53 -0500 Subject: [PATCH 2/4] Update python/samples/getting_started/observability/README.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- python/samples/getting_started/observability/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/samples/getting_started/observability/README.md b/python/samples/getting_started/observability/README.md index 8a1a29e061..d0456b8de3 100644 --- a/python/samples/getting_started/observability/README.md +++ b/python/samples/getting_started/observability/README.md @@ -24,7 +24,7 @@ Next to what happens in the code when you run, we also make setting up observabi ### MCP trace propagation -When observability is enabled, Agent Framework automatically propagates trace context to MCP servers via the `params._meta` field of `tools/call` requests. It uses the globally-configured OpenTelemetry propagator(s) (W3C Trace Context by default, producing `traceparent` and `tracestate`), so custom propagators (B3, Jaeger, etc.) are also supported. This enables distributed tracing across agent-to-MCP-server boundaries for all transports (stdio, HTTP, WebSocket), compliant with the [MCP `_meta` specification](https://modelcontextprotocol.io/specification/2025-06-18/basic#_meta). +Whenever there is an active OpenTelemetry span context, Agent Framework automatically propagates trace context to MCP servers via the `params._meta` field of `tools/call` requests. It uses the globally-configured OpenTelemetry propagator(s) (W3C Trace Context by default, producing `traceparent` and `tracestate`), so custom propagators (B3, Jaeger, etc.) are also supported. This enables distributed tracing across agent-to-MCP-server boundaries for all transports (stdio, HTTP, WebSocket), compliant with the [MCP `_meta` specification](https://modelcontextprotocol.io/specification/2025-11-25/basic#_meta). ### Five patterns for configuring observability From a6ed66570291149bbd715d2eda834b2eea87d924 Mon Sep 17 00:00:00 2001 From: Pete Roden Date: Tue, 10 Feb 2026 09:08:43 -0500 Subject: [PATCH 3/4] Update python/packages/core/tests/core/test_mcp.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- python/packages/core/tests/core/test_mcp.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/python/packages/core/tests/core/test_mcp.py b/python/packages/core/tests/core/test_mcp.py index 24b4cd47be..1fd8ac163d 100644 --- a/python/packages/core/tests/core/test_mcp.py +++ b/python/packages/core/tests/core/test_mcp.py @@ -2764,8 +2764,11 @@ def get_mcp_client(self) -> _AsyncGeneratorContextManager[Any, None]: meta = server.session.call_tool.call_args.kwargs.get("meta") if expect_traceparent: + # When a valid span is active, we expect some propagation fields to be injected, + # but we do not assume any specific header name to keep this test propagator-agnostic. assert meta is not None - assert "traceparent" in meta + assert isinstance(meta, dict) + assert len(meta) > 0 else: assert meta is None From e37c5650228e47bf32c813beb724f992acd5a17b Mon Sep 17 00:00:00 2001 From: Pete Roden Date: Tue, 10 Feb 2026 14:21:40 +0000 Subject: [PATCH 4/4] refactor: move opentelemetry import to module level OpenTelemetry is a hard dependency of agent-framework-core (per pyproject.toml), so the try/except ImportError guard was dead code. Move the import to the top of the file to fail fast on missing dependencies instead of silently hiding installation issues. --- python/packages/core/agent_framework/_mcp.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/python/packages/core/agent_framework/_mcp.py b/python/packages/core/agent_framework/_mcp.py index adb455d4ef..076913df43 100644 --- a/python/packages/core/agent_framework/_mcp.py +++ b/python/packages/core/agent_framework/_mcp.py @@ -24,6 +24,7 @@ from mcp.shared.context import RequestContext from mcp.shared.exceptions import McpError from mcp.shared.session import RequestResponder +from opentelemetry import propagate from pydantic import BaseModel, create_model from ._tools import ( @@ -304,11 +305,6 @@ def _normalize_mcp_name(name: str) -> str: def _inject_otel_into_mcp_meta(meta: dict[str, Any] | None = None) -> dict[str, Any] | None: """Inject OpenTelemetry trace context into MCP request _meta via the global propagator(s).""" - try: - from opentelemetry import propagate - except ImportError: # pragma: no cover - return meta - carrier: dict[str, str] = {} propagate.inject(carrier) if not carrier: