Replace httpx and httpx-sse with httpx2 (#2972)
Co-authored-by: Max Isbey <224885523+maxisbey@users.noreply.github.com>
This commit is contained in:
parent
1216c53693
commit
2713b53b12
3 changed files with 1029 additions and 948 deletions
|
|
@ -1,6 +1,6 @@
|
|||
"""Tests for httpx utility functions."""
|
||||
"""Tests for httpx2 utility functions."""
|
||||
|
||||
import httpx
|
||||
import httpx2
|
||||
|
||||
from mcp.shared._httpx_utils import create_mcp_http_client
|
||||
|
||||
|
|
@ -16,7 +16,7 @@ def test_default_settings():
|
|||
def test_custom_parameters():
|
||||
"""Test custom headers and timeout are set correctly."""
|
||||
headers = {"Authorization": "Bearer token"}
|
||||
timeout = httpx.Timeout(60.0)
|
||||
timeout = httpx2.Timeout(60.0)
|
||||
|
||||
client = create_mcp_http_client(headers, timeout)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -16,11 +16,11 @@ from unittest.mock import MagicMock
|
|||
from urllib.parse import urlparse
|
||||
|
||||
import anyio
|
||||
import httpx
|
||||
import httpx2
|
||||
import mcp_types as types
|
||||
import pytest
|
||||
from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream
|
||||
from httpx_sse import ServerSentEvent
|
||||
from httpx2 import ServerSentEvent
|
||||
from mcp_types import (
|
||||
DEFAULT_NEGOTIATED_VERSION,
|
||||
INVALID_PARAMS,
|
||||
|
|
@ -85,7 +85,7 @@ BASE_URL = "http://127.0.0.1:8000"
|
|||
|
||||
|
||||
# Helper functions
|
||||
def first_sse_data(response: httpx.Response) -> dict[str, Any]:
|
||||
def first_sse_data(response: httpx2.Response) -> dict[str, Any]:
|
||||
"""Return the first SSE `data:` payload of a response, parsed as JSON."""
|
||||
assert response.headers.get("Content-Type") == "text/event-stream"
|
||||
for line in response.text.splitlines():
|
||||
|
|
@ -94,7 +94,7 @@ def first_sse_data(response: httpx.Response) -> dict[str, Any]:
|
|||
raise ValueError("No data event in SSE response") # pragma: no cover
|
||||
|
||||
|
||||
def extract_protocol_version_from_sse(response: httpx.Response) -> str:
|
||||
def extract_protocol_version_from_sse(response: httpx2.Response) -> str:
|
||||
"""Extract the negotiated protocol version from an SSE initialization response."""
|
||||
return first_sse_data(response)["result"]["protocolVersion"]
|
||||
|
||||
|
|
@ -356,13 +356,13 @@ async def running_app(
|
|||
yield app
|
||||
|
||||
|
||||
def make_client(app: Starlette, headers: dict[str, str] | None = None) -> httpx.AsyncClient:
|
||||
"""An httpx client served in process by `app`, with create_mcp_http_client's redirect default.
|
||||
def make_client(app: Starlette, headers: dict[str, str] | None = None) -> httpx2.AsyncClient:
|
||||
"""An httpx2 client served in process by `app`, with create_mcp_http_client's redirect default.
|
||||
|
||||
(Starlette's Mount 307-redirects the bare /mcp path to /mcp/, which the SDK's own client
|
||||
factory follows.)
|
||||
"""
|
||||
return httpx.AsyncClient(
|
||||
return httpx2.AsyncClient(
|
||||
transport=StreamingASGITransport(app), base_url=BASE_URL, headers=headers, follow_redirects=True
|
||||
)
|
||||
|
||||
|
|
@ -400,7 +400,7 @@ async def event_app(event_store: SimpleEventStore) -> AsyncIterator[tuple[Simple
|
|||
async def test_accept_header_validation(basic_app: Starlette) -> None:
|
||||
"""A POST without an Accept header is rejected with 406."""
|
||||
async with make_client(basic_app) as client:
|
||||
# Suppress the httpx client default Accept: */* header
|
||||
# Suppress the httpx2 client default Accept: */* header
|
||||
del client.headers["accept"]
|
||||
response = await client.post(
|
||||
"/mcp",
|
||||
|
|
@ -716,7 +716,7 @@ async def test_json_response_accept_json_only(json_app: Starlette) -> None:
|
|||
async def test_json_response_missing_accept_header(json_app: Starlette) -> None:
|
||||
"""JSON response mode still rejects requests without an Accept header."""
|
||||
async with make_client(json_app) as client:
|
||||
# Suppress the httpx client default Accept: */* header
|
||||
# Suppress the httpx2 client default Accept: */* header
|
||||
del client.headers["accept"]
|
||||
response = await client.post(
|
||||
"/mcp",
|
||||
|
|
@ -829,7 +829,7 @@ async def test_get_validation(basic_app: Starlette) -> None:
|
|||
assert session_id is not None
|
||||
negotiated_version = extract_protocol_version_from_sse(init_response)
|
||||
|
||||
# Test without Accept header (suppress the httpx client default Accept: */*)
|
||||
# Test without Accept header (suppress the httpx2 client default Accept: */*)
|
||||
del client.headers["accept"]
|
||||
response = await client.get(
|
||||
"/mcp",
|
||||
|
|
@ -999,16 +999,16 @@ async def test_streamable_http_client_get_stream(basic_app: Starlette) -> None:
|
|||
assert resource_update_found, "ResourceUpdatedNotification not received via GET stream"
|
||||
|
||||
|
||||
def create_session_id_capturing_client(app: Starlette) -> tuple[httpx.AsyncClient, list[str]]:
|
||||
"""Create an in-process httpx client that captures the session ID from responses."""
|
||||
def create_session_id_capturing_client(app: Starlette) -> tuple[httpx2.AsyncClient, list[str]]:
|
||||
"""Create an in-process httpx2 client that captures the session ID from responses."""
|
||||
captured_ids: list[str] = []
|
||||
|
||||
async def capture_session_id(response: httpx.Response) -> None:
|
||||
async def capture_session_id(response: httpx2.Response) -> None:
|
||||
session_id = response.headers.get(MCP_SESSION_ID_HEADER)
|
||||
if session_id:
|
||||
captured_ids.append(session_id)
|
||||
|
||||
client = httpx.AsyncClient(
|
||||
client = httpx2.AsyncClient(
|
||||
transport=StreamingASGITransport(app),
|
||||
base_url=BASE_URL,
|
||||
follow_redirects=True,
|
||||
|
|
@ -1020,7 +1020,7 @@ def create_session_id_capturing_client(app: Starlette) -> tuple[httpx.AsyncClien
|
|||
@pytest.mark.anyio
|
||||
async def test_streamable_http_client_session_termination(basic_app: Starlette) -> None:
|
||||
"""After the client terminates its session on close, a new connection with that session ID fails."""
|
||||
# Use httpx client with event hooks to capture session ID
|
||||
# Use httpx2 client with event hooks to capture session ID
|
||||
httpx_client, captured_ids = create_session_id_capturing_client(basic_app)
|
||||
|
||||
async with httpx_client:
|
||||
|
|
@ -1060,19 +1060,19 @@ async def test_streamable_http_client_session_termination_204(
|
|||
) -> None:
|
||||
"""Session termination also succeeds when the server answers the DELETE with 204.
|
||||
|
||||
This test patches the httpx client to return a 204 response for DELETEs.
|
||||
This test patches the httpx2 client to return a 204 response for DELETEs.
|
||||
"""
|
||||
|
||||
# Save the original delete method to restore later
|
||||
original_delete = httpx.AsyncClient.delete
|
||||
original_delete = httpx2.AsyncClient.delete
|
||||
|
||||
# Mock the client's delete method to return a 204
|
||||
async def mock_delete(self: httpx.AsyncClient, *args: Any, **kwargs: Any) -> httpx.Response:
|
||||
async def mock_delete(self: httpx2.AsyncClient, *args: Any, **kwargs: Any) -> httpx2.Response:
|
||||
# Call the original method to get the real response
|
||||
response = await original_delete(self, *args, **kwargs)
|
||||
|
||||
# Create a new response with 204 status code but same headers
|
||||
mocked_response = httpx.Response(
|
||||
mocked_response = httpx2.Response(
|
||||
204,
|
||||
headers=response.headers,
|
||||
content=response.content,
|
||||
|
|
@ -1080,10 +1080,10 @@ async def test_streamable_http_client_session_termination_204(
|
|||
)
|
||||
return mocked_response
|
||||
|
||||
# Apply the patch to the httpx client
|
||||
monkeypatch.setattr(httpx.AsyncClient, "delete", mock_delete)
|
||||
# Apply the patch to the httpx2 client
|
||||
monkeypatch.setattr(httpx2.AsyncClient, "delete", mock_delete)
|
||||
|
||||
# Use httpx client with event hooks to capture session ID
|
||||
# Use httpx2 client with event hooks to capture session ID
|
||||
httpx_client, captured_ids = create_session_id_capturing_client(basic_app)
|
||||
|
||||
async with httpx_client:
|
||||
|
|
@ -1143,7 +1143,7 @@ async def test_streamable_http_client_resumption(event_app: tuple[SimpleEventSto
|
|||
captured_resumption_token = token
|
||||
resumption_token_received.set()
|
||||
|
||||
# Use httpx client with event hooks to capture session ID
|
||||
# Use httpx2 client with event hooks to capture session ID
|
||||
httpx_client, captured_ids = create_session_id_capturing_client(app)
|
||||
|
||||
# First, start the client session and begin the tool that waits on lock
|
||||
|
|
@ -1614,7 +1614,7 @@ async def test_handle_sse_event_skips_empty_data() -> None:
|
|||
transport = StreamableHTTPTransport(url="http://localhost:8000/mcp")
|
||||
|
||||
# Create a mock SSE event with empty data (keep-alive ping)
|
||||
mock_sse = ServerSentEvent(event="message", data="", id=None, retry=None)
|
||||
mock_sse = ServerSentEvent(event="message", data="")
|
||||
|
||||
# Create a context-aware stream writer (matches StreamWriter type alias)
|
||||
write_stream, read_stream = create_context_streams[SessionMessage | Exception](1)
|
||||
|
|
@ -2081,7 +2081,7 @@ async def test_standalone_get_stream_reconnection(event_app: tuple[SimpleEventSt
|
|||
|
||||
@pytest.mark.anyio
|
||||
async def test_streamable_http_client_does_not_mutate_provided_client(basic_app: Starlette) -> None:
|
||||
"""streamable_http_client does not mutate the provided httpx client's headers."""
|
||||
"""streamable_http_client does not mutate the provided httpx2 client's headers."""
|
||||
# Create a client with custom headers
|
||||
original_headers = {
|
||||
"X-Custom-Header": "custom-value",
|
||||
|
|
@ -2099,7 +2099,7 @@ async def test_streamable_http_client_does_not_mutate_provided_client(basic_app:
|
|||
assert isinstance(result, InitializeResult)
|
||||
|
||||
# Verify client headers were not mutated with MCP protocol headers
|
||||
# If accept header exists, it should still be httpx default, not MCP's
|
||||
# If accept header exists, it should still be httpx2 default, not MCP's
|
||||
if "accept" in custom_client.headers: # pragma: no branch
|
||||
assert custom_client.headers.get("accept") == "*/*"
|
||||
# MCP content-type should not have been added
|
||||
|
|
@ -2112,8 +2112,8 @@ async def test_streamable_http_client_does_not_mutate_provided_client(basic_app:
|
|||
|
||||
@pytest.mark.anyio
|
||||
async def test_streamable_http_client_mcp_headers_override_defaults(context_app: Starlette) -> None:
|
||||
"""MCP protocol headers override the httpx client's default headers in actual requests."""
|
||||
# httpx.AsyncClient has default "accept: */*" header
|
||||
"""MCP protocol headers override the httpx2 client's default headers in actual requests."""
|
||||
# httpx2.AsyncClient has default "accept: */*" header
|
||||
# We need to verify that our MCP accept header overrides it in actual requests
|
||||
|
||||
async with make_client(context_app) as client:
|
||||
|
|
@ -2130,7 +2130,7 @@ async def test_streamable_http_client_mcp_headers_override_defaults(context_app:
|
|||
assert isinstance(tool_result.content[0], TextContent)
|
||||
headers_data = json.loads(tool_result.content[0].text)
|
||||
|
||||
# Verify MCP protocol headers were sent (not httpx defaults)
|
||||
# Verify MCP protocol headers were sent (not httpx2 defaults)
|
||||
assert "accept" in headers_data
|
||||
assert "application/json" in headers_data["accept"]
|
||||
assert "text/event-stream" in headers_data["accept"]
|
||||
|
|
|
|||
Loading…
Reference in a new issue