diff --git a/python/packages/core/agent_framework/_mcp.py b/python/packages/core/agent_framework/_mcp.py index d716aa0c94..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 ( @@ -302,6 +303,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 @@ -765,10 +782,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..1fd8ac163d 100644 --- a/python/packages/core/tests/core/test_mcp.py +++ b/python/packages/core/tests/core/test_mcp.py @@ -2707,3 +2707,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/getting_started/observability/README.md b/python/samples/getting_started/observability/README.md index d42162b23c..d0456b8de3 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 + +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: