Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 21 additions & 1 deletion python/packages/core/agent_framework/_mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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


Expand Down Expand Up @@ -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:
Expand Down
67 changes: 67 additions & 0 deletions python/packages/core/tests/core/test_mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
4 changes: 4 additions & 0 deletions python/samples/getting_started/observability/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading