diff --git a/python/packages/core/agent_framework/_mcp.py b/python/packages/core/agent_framework/_mcp.py index 2be5e452ce..0c241cb89a 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 ._tools import ( FunctionTool, @@ -380,6 +381,22 @@ 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).""" + 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 @@ -875,12 +892,15 @@ async def call_tool(self, tool_name: str, **kwargs: Any) -> str: } } + # Inject OpenTelemetry trace context into MCP _meta for distributed tracing. + otel_meta = _inject_otel_into_mcp_meta() + parser = self.parse_tool_results or _parse_tool_result_from_mcp # 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 return parser(result) except ClosedResourceError as cl_ex: if attempt == 0: diff --git a/python/packages/core/tests/core/test_mcp.py b/python/packages/core/tests/core/test_mcp.py index 8b213476aa..09d5b11754 100644 --- a/python/packages/core/tests/core/test_mcp.py +++ b/python/packages/core/tests/core/test_mcp.py @@ -2591,3 +2591,70 @@ 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: + # 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 isinstance(meta, dict) + assert len(meta) > 0 + else: + assert meta is None + + +# endregion diff --git a/python/samples/02-agents/observability/README.md b/python/samples/02-agents/observability/README.md index fa6ba0fec1..b311138714 100644 --- a/python/samples/02-agents/observability/README.md +++ b/python/samples/02-agents/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 + +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 We've identified multiple ways to configure observability in your application, depending on your needs: