Replace httpx and httpx-sse with httpx2 (#2972)
Some checks failed
CI / checks (push) Has been cancelled
CI / all-green (push) Has been cancelled

Co-authored-by: Max Isbey <224885523+maxisbey@users.noreply.github.com>
This commit is contained in:
Marcelo Trylesinski 2026-07-14 18:05:08 +02:00 • committed by GitHub
commit 2713b53b12
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -3,14 +3,14 @@
import json
from collections.abc import AsyncGenerator
from typing import Any
from unittest.mock import AsyncMock, MagicMock, Mock, patch
from unittest.mock import AsyncMock, MagicMock, Mock
from urllib.parse import urlparse
import anyio
import httpx
import httpx2
import mcp_types as types
import pytest
from httpx_sse import ServerSentEvent
from httpx2 import ServerSentEvent
from inline_snapshot import snapshot
from mcp_types import (
CallToolRequestParams,
@ -54,13 +54,13 @@ def in_process_client_factory(app: Starlette) -> McpHttpClientFactory:
def factory(
headers: dict[str, str] | None = None,
timeout: httpx.Timeout | None = None,
auth: httpx.Auth | None = None,
) -> httpx.AsyncClient:
timeout: httpx2.Timeout | None = None,
auth: httpx2.Auth | None = None,
) -> httpx2.AsyncClient:
# The SSE GET runs until it observes a disconnect, so the bridge must let the
# application drain on close rather than cancelling it. follow_redirects matches
# create_mcp_http_client, the factory this one stands in for.
return httpx.AsyncClient(
return httpx2.AsyncClient(
transport=StreamingASGITransport(app, cancel_on_close=False),
base_url=BASE_URL,
headers=headers,
@ -112,7 +112,7 @@ def make_server_app() -> Starlette:
async def test_raw_sse_connection() -> None:
"""The SSE GET responds 200 with an event-stream content type, announcing the session
endpoint as its first event."""
http_client = httpx.AsyncClient(
http_client = httpx2.AsyncClient(
transport=StreamingASGITransport(make_server_app(), cancel_on_close=False), base_url=BASE_URL
)
@ -416,40 +416,40 @@ async def test_sse_client_handles_empty_keepalive_pings() -> None:
)
response_json = response.model_dump_json(by_alias=True, exclude_none=True)
# Create mock SSE events using httpx_sse's ServerSentEvent
async def mock_aiter_sse() -> AsyncGenerator[ServerSentEvent, None]:
# First: endpoint event
yield ServerSentEvent(event="endpoint", data="/messages/?session_id=abc123")
# Empty data keep-alive ping - this is what we're testing
yield ServerSentEvent(event="message", data="")
# Real JSON-RPC response
yield ServerSentEvent(event="message", data=response_json)
# Mock SSE events using httpx2's ServerSentEvent: an endpoint event, an
# empty keep-alive ping (the case under test), then a real response.
mock_event_source = MagicMock()
mock_event_source.aiter_sse.return_value = mock_aiter_sse()
mock_event_source.response = MagicMock()
mock_event_source.__aiter__.return_value = [
ServerSentEvent(event="endpoint", data="/messages/?session_id=abc123"),
ServerSentEvent(event="message", data=""),
ServerSentEvent(event="message", data=response_json),
]
mock_event_source.response.raise_for_status = MagicMock()
mock_aconnect_sse = MagicMock()
mock_aconnect_sse.__aenter__ = AsyncMock(return_value=mock_event_source)
mock_aconnect_sse.__aexit__ = AsyncMock(return_value=None)
mock_sse = MagicMock()
mock_sse.__aenter__ = AsyncMock(return_value=mock_event_source)
mock_sse.__aexit__ = AsyncMock(return_value=None)
mock_client = MagicMock()
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=None)
mock_client.sse = MagicMock(return_value=mock_sse)
mock_client.post = AsyncMock(return_value=MagicMock(status_code=200, raise_for_status=MagicMock()))
with (
patch("mcp.client.sse.create_mcp_http_client", return_value=mock_client),
patch("mcp.client.sse.aconnect_sse", return_value=mock_aconnect_sse),
):
async with sse_client("http://test/sse") as (read_stream, _):
# Read the message - should skip the empty one and get the real response
msg = await read_stream.receive()
# If we get here without error, the empty message was skipped successfully
assert not isinstance(msg, Exception)
assert isinstance(msg.message, types.JSONRPCResponse)
assert msg.message.id == 1
def mock_factory(
headers: dict[str, str] | None = None,
timeout: httpx2.Timeout | None = None,
auth: httpx2.Auth | None = None,
) -> httpx2.AsyncClient:
return mock_client
async with sse_client("http://test/sse", httpx_client_factory=mock_factory) as (read_stream, _):
# Read the message - should skip the empty one and get the real response
msg = await read_stream.receive()
# If we get here without error, the empty message was skipped successfully
assert not isinstance(msg, Exception)
assert isinstance(msg.message, types.JSONRPCResponse)
assert msg.message.id == 1
@pytest.mark.anyio