Skip to content

Commit 45109d2

Browse files
committed
Revert SSE parsing workaround - fixed in httpx-sse 0.4.2
The Unicode line separator issue (U+2028 and U+2029 characters being incorrectly treated as newlines) has been fixed in httpx-sse 0.4.2. See: florimondmanca/httpx-sse#39 Revert the compliant_aiter_sse workaround and use the standard event_source.aiter_sse() method again. Upgrade httpx-sse to >=0.4.2 to get the fix. Keep the high-level issue test to ensure the problem doesn't regress. Github-Issue:#1356
1 parent 1c2d4c6 commit 45109d2

File tree

5 files changed

+9
-239
lines changed

5 files changed

+9
-239
lines changed

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ classifiers = [
2424
dependencies = [
2525
"anyio>=4.5",
2626
"httpx>=0.27.1",
27-
"httpx-sse>=0.4",
27+
"httpx-sse>=0.4.2",
2828
"pydantic>=2.11.0,<3.0.0",
2929
"starlette>=0.27",
3030
"python-multipart>=0.0.9",

src/mcp/client/sse.py

Lines changed: 2 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import logging
2-
from collections.abc import AsyncIterator
32
from contextlib import asynccontextmanager
43
from typing import Any
54
from urllib.parse import urljoin, urlparse
@@ -8,8 +7,7 @@
87
import httpx
98
from anyio.abc import TaskStatus
109
from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream
11-
from httpx_sse import EventSource, ServerSentEvent, aconnect_sse
12-
from httpx_sse._decoders import SSEDecoder
10+
from httpx_sse import aconnect_sse
1311

1412
import mcp.types as types
1513
from mcp.shared._httpx_utils import McpHttpClientFactory, create_mcp_http_client
@@ -22,68 +20,6 @@ def remove_request_params(url: str) -> str:
2220
return urljoin(url, urlparse(url).path)
2321

2422

25-
async def compliant_aiter_sse(event_source: EventSource) -> AsyncIterator[ServerSentEvent]:
26-
"""
27-
Safely iterate over SSE events, working around httpx issue where U+2028 and U+2029
28-
are incorrectly treated as newlines, breaking SSE stream parsing.
29-
30-
This function replaces event_source.aiter_sse() to handle these Unicode characters
31-
correctly by processing the raw byte stream and only splitting on actual newlines.
32-
33-
Args:
34-
event_source: The EventSource to iterate over
35-
36-
Yields:
37-
ServerSentEvent objects parsed from the stream
38-
"""
39-
decoder = SSEDecoder()
40-
buffer = b""
41-
42-
# Split on "\r\n", "\r", or "\n" only, no other new line characters.
43-
# https://html.spec.whatwg.org/multipage/server-sent-events.html#parsing-an-event-stream
44-
45-
# Note: this is tricky, because we could have a "\r" at the end of a chunk and not yet
46-
# know if the next chunk starts with a "\n" or not.
47-
skip_leading_lf = False
48-
49-
async for chunk in event_source.response.aiter_bytes():
50-
buffer += chunk
51-
52-
while len(buffer) != 0:
53-
if skip_leading_lf and buffer.startswith(b"\n"):
54-
buffer = buffer[1:]
55-
skip_leading_lf = False
56-
57-
# Find first "\r" or "\n"
58-
cr = buffer.find(b"\r")
59-
lf = buffer.find(b"\n")
60-
pos = cr if lf == -1 else lf if cr == -1 else min(cr, lf)
61-
62-
if pos == -1:
63-
# No lines, need another chunk
64-
break
65-
66-
line_bytes = buffer[:pos]
67-
buffer = buffer[pos + 1 :]
68-
69-
# If we have a CR first, skip any LF immediately after (may be in next chunk)
70-
skip_leading_lf = pos == cr
71-
72-
line = line_bytes.decode("utf-8", errors="replace")
73-
sse = decoder.decode(line)
74-
if sse is not None:
75-
yield sse
76-
77-
# Process any remaining data in buffer
78-
if buffer:
79-
assert b"\n" not in buffer
80-
assert b"\r" not in buffer
81-
line = buffer.decode("utf-8", errors="replace")
82-
sse = decoder.decode(line)
83-
if sse is not None:
84-
yield sse
85-
86-
8723
@asynccontextmanager
8824
async def sse_client(
8925
url: str,
@@ -133,8 +69,7 @@ async def sse_reader(
13369
task_status: TaskStatus[str] = anyio.TASK_STATUS_IGNORED,
13470
):
13571
try:
136-
# Use our compliant SSE iterator to handle Unicode correctly (issue #1356)
137-
async for sse in compliant_aiter_sse(event_source):
72+
async for sse in event_source.aiter_sse():
13873
logger.debug(f"Received SSE event: {sse.event}")
13974
match sse.event:
14075
case "endpoint":

src/mcp/client/streamable_http.py

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@
1818
from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream
1919
from httpx_sse import EventSource, ServerSentEvent, aconnect_sse
2020

21-
from mcp.client.sse import compliant_aiter_sse
2221
from mcp.shared._httpx_utils import McpHttpClientFactory, create_mcp_http_client
2322
from mcp.shared.message import ClientMessageMetadata, SessionMessage
2423
from mcp.types import (
@@ -212,8 +211,7 @@ async def handle_get_stream(
212211
event_source.response.raise_for_status()
213212
logger.debug("GET SSE connection established")
214213

215-
# Use compliant SSE iterator to handle Unicode correctly (issue #1356)
216-
async for sse in compliant_aiter_sse(event_source):
214+
async for sse in event_source.aiter_sse():
217215
await self._handle_sse_event(sse, read_stream_writer)
218216

219217
except Exception as exc:
@@ -242,8 +240,7 @@ async def _handle_resumption_request(self, ctx: RequestContext) -> None:
242240
event_source.response.raise_for_status()
243241
logger.debug("Resumption GET SSE connection established")
244242

245-
# Use compliant SSE iterator to handle Unicode correctly (issue #1356)
246-
async for sse in compliant_aiter_sse(event_source):
243+
async for sse in event_source.aiter_sse():
247244
is_complete = await self._handle_sse_event(
248245
sse,
249246
ctx.read_stream_writer,
@@ -326,8 +323,7 @@ async def _handle_sse_response(
326323
"""Handle SSE response from the server."""
327324
try:
328325
event_source = EventSource(response)
329-
# Use compliant SSE iterator to handle Unicode correctly (issue #1356)
330-
async for sse in compliant_aiter_sse(event_source):
326+
async for sse in event_source.aiter_sse():
331327
is_complete = await self._handle_sse_event(
332328
sse,
333329
ctx.read_stream_writer,

tests/client/test_sse_unicode.py

Lines changed: 0 additions & 161 deletions
This file was deleted.

uv.lock

Lines changed: 3 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)