-
Notifications
You must be signed in to change notification settings - Fork 1.2k
Python: Fix HTTP call to MCP losing tracing parent (#3623) #3933
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||
|---|---|---|---|---|---|---|---|---|
|
|
@@ -61,6 +61,19 @@ class MCPSpecificApproval(TypedDict, total=False): | |||||||
|
|
||||||||
| logger = logging.getLogger(__name__) | ||||||||
|
|
||||||||
|
|
||||||||
| async def _inject_otel_context(request: httpx.Request) -> None: # noqa: RUF029 | ||||||||
| """Inject OpenTelemetry trace context into outgoing HTTP request headers. | ||||||||
|
|
||||||||
| Used as an httpx event hook to propagate the active span context | ||||||||
| (W3C ``traceparent`` / ``tracestate``) so that remote MCP servers | ||||||||
| can correlate their spans with the calling agent's trace. | ||||||||
| """ | ||||||||
| from opentelemetry.propagate import inject | ||||||||
|
|
||||||||
| inject(carrier=request.headers) | ||||||||
|
|
||||||||
|
|
||||||||
| # region: Helpers | ||||||||
|
|
||||||||
| LOG_LEVEL_MAPPING: dict[types.LoggingLevel, int] = { | ||||||||
|
|
@@ -1244,20 +1257,45 @@ def __init__( | |||||||
| self.url = url | ||||||||
| self.terminate_on_close = terminate_on_close | ||||||||
| self._httpx_client: httpx.AsyncClient | None = http_client | ||||||||
| self._auto_httpx_client: httpx.AsyncClient | None = None | ||||||||
|
|
||||||||
| def _get_instrumented_httpx_client(self) -> httpx.AsyncClient: | ||||||||
| """Return an httpx client with OpenTelemetry trace context propagation. | ||||||||
|
|
||||||||
| If a user-provided client exists, the trace injection hook is added to it | ||||||||
| (idempotently). Otherwise, an internally managed client is created and cached. | ||||||||
| """ | ||||||||
| if self._httpx_client is not None: | ||||||||
| client = self._httpx_client | ||||||||
| if _inject_otel_context not in client.event_hooks.get("request", []): | ||||||||
| client.event_hooks.setdefault("request", []).append(_inject_otel_context) | ||||||||
| return client | ||||||||
|
|
||||||||
| if self._auto_httpx_client is None: | ||||||||
| self._auto_httpx_client = httpx.AsyncClient( | ||||||||
| event_hooks={"request": [_inject_otel_context]} | ||||||||
|
||||||||
| event_hooks={"request": [_inject_otel_context]} | |
| event_hooks={"request": [_inject_otel_context]}, | |
| timeout=httpx.Timeout(None), |
Copilot
AI
Feb 13, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
get_mcp_client() now always passes an internally created httpx client, but the constructor docstring/comments above still state that streamable_http_client will create/manage a default client when http_client isn’t provided. Please update the docs to reflect the new ownership model (tool-managed cached client + close() cleanup).
| Original file line number | Diff line number | Diff line change | ||||||
|---|---|---|---|---|---|---|---|---|
|
|
@@ -1489,13 +1489,14 @@ def test_mcp_streamable_http_tool_get_mcp_client_all_params(): | |||||||
| with patch("agent_framework._mcp.streamable_http_client") as mock_http_client: | ||||||||
| tool.get_mcp_client() | ||||||||
|
|
||||||||
| # Verify streamable_http_client was called with None for http_client | ||||||||
| # (since we didn't provide one, the API will create its own) | ||||||||
| mock_http_client.assert_called_once_with( | ||||||||
| url="http://example.com", | ||||||||
| http_client=None, | ||||||||
| terminate_on_close=True, | ||||||||
| ) | ||||||||
| # An auto-created httpx client with OTel trace injection should be passed | ||||||||
| mock_http_client.assert_called_once() | ||||||||
| call_kwargs = mock_http_client.call_args.kwargs | ||||||||
| assert call_kwargs["url"] == "http://example.com" | ||||||||
| assert call_kwargs["terminate_on_close"] is True | ||||||||
| # The http_client should be an auto-created instrumented client, not None | ||||||||
| assert call_kwargs["http_client"] is not None | ||||||||
| assert call_kwargs["http_client"] is tool._auto_httpx_client | ||||||||
|
Comment on lines
+1492
to
+1499
|
||||||||
|
|
||||||||
|
|
||||||||
| def test_mcp_websocket_tool_get_mcp_client_with_kwargs(): | ||||||||
|
|
@@ -1696,6 +1697,7 @@ async def test_mcp_streamable_http_tool_httpx_client_cleanup(): | |||||||
|
|
||||||||
| # Test 2: Tool with user-provided client | ||||||||
| user_client = Mock() | ||||||||
| user_client.event_hooks = {"request": [], "response": []} | ||||||||
| tool2 = MCPStreamableHTTPTool( | ||||||||
| name="test", | ||||||||
| url="http://localhost:8081/mcp", | ||||||||
|
|
@@ -1715,6 +1717,63 @@ async def test_mcp_streamable_http_tool_httpx_client_cleanup(): | |||||||
| assert call_args.kwargs["http_client"] is user_client, "User's client should be passed through" | ||||||||
|
|
||||||||
|
|
||||||||
| async def test_mcp_streamable_http_tool_otel_trace_injection(): | ||||||||
| """Test that MCPStreamableHTTPTool injects OpenTelemetry trace context into outgoing requests.""" | ||||||||
| from agent_framework._mcp import _inject_otel_context | ||||||||
|
|
||||||||
| # Test 1: Auto-created client gets the OTel hook | ||||||||
| tool = MCPStreamableHTTPTool( | ||||||||
| name="test", | ||||||||
| url="http://localhost:8081/mcp", | ||||||||
| ) | ||||||||
| with patch("agent_framework._mcp.streamable_http_client") as mock_client: | ||||||||
| tool.get_mcp_client() | ||||||||
| call_kwargs = mock_client.call_args.kwargs | ||||||||
| client = call_kwargs["http_client"] | ||||||||
| assert _inject_otel_context in client.event_hooks["request"], ( | ||||||||
| "Auto-created client should have OTel trace injection hook" | ||||||||
| ) | ||||||||
|
Comment on lines
+1720
to
+1735
|
||||||||
| ) | |
| ) | |
| await tool.close() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
_inject_otel_contextis declaredasyncbut contains noawait(hence the# noqa: RUF029). Since httpx request event hooks can be regular callables too, consider making this a synchronous function and dropping the suppression to avoid unnecessary coroutine overhead and lint ignores.