Skip to content

Commit dae920c

Browse files
committed
Restore SSE request headers via AsyncClient.sse(); fix example fallout
httpx-sse's aconnect_sse() always sent Accept: text/event-stream and Cache-Control: no-store; the swap to bare client.stream() dropped both. Open the legacy SSE GET and the streamable HTTP GET/resumption/ reconnection streams with AsyncClient.sse(), which injects those headers (explicit caller headers still take precedence), and update the mocked sse_client test to drive the new call. Example fixes from the same review pass: - simple-chatbot: catch httpx2.HTTPError instead of RequestError so raise_for_status() failures take the handled path (the HTTPStatusError isinstance branch was unreachable), and drop the Raises section the method never honoured. - sse-polling-client: suppress the httpcore2 logger; httpcore is no longer in the dependency tree, so the old suppression was a no-op.
1 parent b8775a1 commit dae920c

4 files changed

Lines changed: 27 additions & 34 deletions

File tree

examples/clients/simple-chatbot/mcp_simple_chatbot/main.py

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -227,10 +227,7 @@ def get_response(self, messages: list[dict[str, str]]) -> str:
227227
messages: A list of message dictionaries.
228228
229229
Returns:
230-
The LLM's response as a string.
231-
232-
Raises:
233-
httpx2.RequestError: If the request to the LLM fails.
230+
The LLM's response as a string, or an error message if the request fails.
234231
"""
235232
url = "https://api.groq.com/openai/v1/chat/completions"
236233

@@ -255,7 +252,7 @@ def get_response(self, messages: list[dict[str, str]]) -> str:
255252
data = response.json()
256253
return data["choices"][0]["message"]["content"]
257254

258-
except httpx2.RequestError as e:
255+
except httpx2.HTTPError as e:
259256
error_message = f"Error getting LLM response: {str(e)}"
260257
logging.error(error_message)
261258

examples/clients/sse-polling-client/mcp_sse_polling_client/main.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ def main(url: str, items: int, checkpoint_every: int, log_level: str) -> None:
9393
)
9494
# Suppress noisy HTTP client logging
9595
logging.getLogger("httpx2").setLevel(logging.WARNING)
96-
logging.getLogger("httpcore").setLevel(logging.WARNING)
96+
logging.getLogger("httpcore2").setLevel(logging.WARNING)
9797

9898
asyncio.run(run_demo(url, items, checkpoint_every))
9999

src/mcp/client/sse.py

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
import httpx2
99
import mcp_types as types
1010
from anyio.abc import TaskStatus
11-
from httpx2 import EventSource, SSEError
11+
from httpx2 import SSEError
1212

1313
from mcp.shared._compat import resync_tracer
1414
from mcp.shared._context_streams import create_context_streams
@@ -55,9 +55,8 @@ async def sse_client(
5555
async with httpx_client_factory(
5656
headers=headers, auth=auth, timeout=httpx2.Timeout(timeout, read=sse_read_timeout)
5757
) as client:
58-
async with client.stream("GET", url) as response:
59-
event_source = EventSource(response)
60-
response.raise_for_status()
58+
async with client.sse(url) as event_source:
59+
event_source.response.raise_for_status()
6160
logger.debug("SSE connection established")
6261

6362
read_stream_writer, read_stream = create_context_streams[SessionMessage | Exception](0)

tests/shared/test_sse.py

Lines changed: 21 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import json
44
from collections.abc import AsyncGenerator
55
from typing import Any
6-
from unittest.mock import AsyncMock, MagicMock, Mock, patch
6+
from unittest.mock import AsyncMock, MagicMock, Mock
77
from urllib.parse import urlparse
88

99
import anyio
@@ -416,26 +416,24 @@ async def test_sse_client_handles_empty_keepalive_pings() -> None:
416416
)
417417
response_json = response.model_dump_json(by_alias=True, exclude_none=True)
418418

419-
# Create mock SSE events using httpx2's ServerSentEvent
420-
async def mock_aiter_sse() -> AsyncGenerator[ServerSentEvent, None]:
421-
# First: endpoint event
422-
yield ServerSentEvent(event="endpoint", data="/messages/?session_id=abc123")
423-
# Empty data keep-alive ping - this is what we're testing
424-
yield ServerSentEvent(event="message", data="")
425-
# Real JSON-RPC response
426-
yield ServerSentEvent(event="message", data=response_json)
419+
# Mock SSE events using httpx2's ServerSentEvent: an endpoint event, an
420+
# empty keep-alive ping (the case under test), then a real response.
421+
mock_event_source = MagicMock()
422+
mock_event_source.__aiter__.return_value = [
423+
ServerSentEvent(event="endpoint", data="/messages/?session_id=abc123"),
424+
ServerSentEvent(event="message", data=""),
425+
ServerSentEvent(event="message", data=response_json),
426+
]
427+
mock_event_source.response.raise_for_status = MagicMock()
427428

428-
mock_response = MagicMock()
429-
mock_response.raise_for_status = MagicMock()
430-
431-
mock_stream = MagicMock()
432-
mock_stream.__aenter__ = AsyncMock(return_value=mock_response)
433-
mock_stream.__aexit__ = AsyncMock(return_value=None)
429+
mock_sse = MagicMock()
430+
mock_sse.__aenter__ = AsyncMock(return_value=mock_event_source)
431+
mock_sse.__aexit__ = AsyncMock(return_value=None)
434432

435433
mock_client = MagicMock()
436434
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
437435
mock_client.__aexit__ = AsyncMock(return_value=None)
438-
mock_client.stream = MagicMock(return_value=mock_stream)
436+
mock_client.sse = MagicMock(return_value=mock_sse)
439437
mock_client.post = AsyncMock(return_value=MagicMock(status_code=200, raise_for_status=MagicMock()))
440438

441439
def mock_factory(
@@ -445,14 +443,13 @@ def mock_factory(
445443
) -> httpx2.AsyncClient:
446444
return mock_client
447445

448-
with patch("mcp.client.sse.EventSource", return_value=mock_aiter_sse()):
449-
async with sse_client("http://test/sse", httpx_client_factory=mock_factory) as (read_stream, _):
450-
# Read the message - should skip the empty one and get the real response
451-
msg = await read_stream.receive()
452-
# If we get here without error, the empty message was skipped successfully
453-
assert not isinstance(msg, Exception)
454-
assert isinstance(msg.message, types.JSONRPCResponse)
455-
assert msg.message.id == 1
446+
async with sse_client("http://test/sse", httpx_client_factory=mock_factory) as (read_stream, _):
447+
# Read the message - should skip the empty one and get the real response
448+
msg = await read_stream.receive()
449+
# If we get here without error, the empty message was skipped successfully
450+
assert not isinstance(msg, Exception)
451+
assert isinstance(msg.message, types.JSONRPCResponse)
452+
assert msg.message.id == 1
456453

457454

458455
@pytest.mark.anyio

0 commit comments

Comments
 (0)