Skip to content

Python: Fix HTTP call to MCP losing tracing parent (#3623)#3933

Closed
alliscode wants to merge 1 commit intomicrosoft:mainfrom
alliscode:issue-3623
Closed

Python: Fix HTTP call to MCP losing tracing parent (#3623)#3933
alliscode wants to merge 1 commit intomicrosoft:mainfrom
alliscode:issue-3623

Conversation

@alliscode
Copy link
Member

Add OpenTelemetry trace context propagation to MCPStreamableHTTPTool's httpx client via an event hook that injects W3C traceparent/tracestate headers into outgoing HTTP requests. This ensures the remote MCP server's spans are correctly parented under the agent framework's Execute Tool span.

  • Add _inject_otel_context async event hook using opentelemetry.propagate.inject
  • Add _get_instrumented_httpx_client to ensure all httpx clients have the hook
  • Cache auto-created httpx clients; clean up in close()
  • Update and add tests for trace context injection

closes #3623

Contribution Checklist

  • The code builds clean without any errors or warnings
  • The PR follows the Contribution Guidelines
  • All unit tests pass, and I have added new tests where possible
  • Is this a breaking change? If yes, add "[BREAKING]" prefix to the title of the PR.

Add OpenTelemetry trace context propagation to MCPStreamableHTTPTool's
httpx client via an event hook that injects W3C traceparent/tracestate
headers into outgoing HTTP requests. This ensures the remote MCP server's
spans are correctly parented under the agent framework's Execute Tool span.

- Add _inject_otel_context async event hook using opentelemetry.propagate.inject
- Add _get_instrumented_httpx_client to ensure all httpx clients have the hook
- Cache auto-created httpx clients; clean up in close()
- Update and add tests for trace context injection

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings February 13, 2026 20:01
@markwallace-microsoft
Copy link
Member

Python Test Coverage

Python Test Coverage Report •
FileStmtsMissCoverMissing
packages/core/agent_framework
   _mcp.py4246684%72, 74, 117–118, 128–133, 144, 149, 195–196, 206–211, 221–222, 274, 283, 346, 354, 375, 489, 556, 591, 593, 597–598, 600–601, 655, 670, 688, 729, 835, 848–853, 875, 921–922, 928–930, 949, 974–975, 979–983, 1000–1004, 1148
TOTAL20494324484% 

Python Unit Test Overview

Tests Skipped Failures Errors Time
3925 225 💤 0 ❌ 0 🔥 1m 8s ⏱️

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds OpenTelemetry trace context propagation for MCP streamable HTTP calls so the remote MCP server can parent spans under the agent framework’s active span during tool execution.

Changes:

  • Introduces an httpx request event hook that injects W3C traceparent/tracestate headers via opentelemetry.propagate.inject.
  • Ensures MCPStreamableHTTPTool always uses an instrumented httpx client (auto-created + cached when not user-supplied) and cleans it up in close().
  • Updates/adds tests around httpx client creation, hook registration, caching, and cleanup.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 6 comments.

File Description
python/packages/core/agent_framework/_mcp.py Adds OTel header injection hook, instrumented httpx client creation/caching, and cleanup on close for MCP streamable HTTP transport.
python/packages/core/tests/core/test_mcp.py Updates expectations for the new instrumented auto-client behavior and adds tests for hook registration/caching/cleanup.


if self._auto_httpx_client is None:
self._auto_httpx_client = httpx.AsyncClient(
event_hooks={"request": [_inject_otel_context]}
Copy link

Copilot AI Feb 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The auto-created httpx.AsyncClient() uses httpx defaults, including a 5s read timeout. For streamable HTTP/SSE this can break long-lived streams or requests with infrequent data. Consider explicitly configuring the auto client with an appropriate timeout (e.g., disabling read timeout) to match the MCP streamable transport’s expectations.

Suggested change
event_hooks={"request": [_inject_otel_context]}
event_hooks={"request": [_inject_otel_context]},
timeout=httpx.Timeout(None),

Copilot uses AI. Check for mistakes.
Comment on lines 1280 to 1290
def get_mcp_client(self) -> _AsyncGeneratorContextManager[Any, None]:
"""Get an MCP streamable HTTP client.

Returns:
An async context manager for the streamable HTTP client transport.
"""
# Pass the http_client (which may be None) to streamable_http_client
return streamable_http_client(
url=self.url,
http_client=self._httpx_client,
http_client=self._get_instrumented_httpx_client(),
terminate_on_close=self.terminate_on_close if self.terminate_on_close is not None else True,
)
Copy link

Copilot AI Feb 13, 2026

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

Copilot uses AI. Check for mistakes.
Comment on lines +1492 to +1499
# 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
Copy link

Copilot AI Feb 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test now triggers creation of a real httpx.AsyncClient (via get_mcp_client()), but it never gets closed. That can cause unclosed-client ResourceWarnings and flaky test runs. Consider making this test async and calling await tool.close() (or otherwise ensuring the auto-created client is closed).

Copilot uses AI. Check for mistakes.
Comment on lines +1720 to +1735
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"
)
Copy link

Copilot AI Feb 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

test_mcp_streamable_http_tool_otel_trace_injection verifies the hook is registered, but it doesn’t assert that traceparent/tracestate headers are actually injected under an active span. Adding an assertion that _inject_otel_context mutates a request’s headers when a span is current would better cover the bug being fixed.

Copilot uses AI. Check for mistakes.
client = call_kwargs["http_client"]
assert _inject_otel_context in client.event_hooks["request"], (
"Auto-created client should have OTel trace injection hook"
)
Copy link

Copilot AI Feb 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In this test, the first MCPStreamableHTTPTool instance (Test 1) creates an auto-managed httpx.AsyncClient, but the tool is never closed. That can leak resources and generate unclosed-client warnings. Ensure the tool is closed (e.g., await tool.close()) after the assertions.

Suggested change
)
)
await tool.close()

Copilot uses AI. Check for mistakes.
Comment on lines +65 to +75
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)

Copy link

Copilot AI Feb 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_inject_otel_context is declared async but contains no await (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.

Copilot uses AI. Check for mistakes.
@alliscode alliscode closed this Feb 13, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Python: [Bug]: HTTP call to MCP loses tracing parent

2 participants