Python: feature: Inject OpenTelemetry trace context into MCP requests and update… (#3780)

* feat: Inject OpenTelemetry trace context into MCP requests and update documentation

* Update python/samples/getting_started/observability/README.md

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update python/packages/core/tests/core/test_mcp.py

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* 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.

---------

Co-authored-by: Pete Roden <Pete.Roden@microsoft.com>
Co-authored-by: Mark Wallace <127216156+markwallace-microsoft@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Pete Roden
2026-02-17 10:22:04 -05:00
committed by GitHub
Unverified
parent bf7056a131
commit 36d52a1f9f
3 changed files with 92 additions and 1 deletions
@@ -2591,3 +2591,70 @@ async def test_mcp_tool_filters_framework_kwargs():
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