Skip to content

Commit d3d0ff4

Browse files
committed
Preserve SSE stream recovery after size errors
1 parent b2fe38d commit d3d0ff4

3 files changed

Lines changed: 97 additions & 60 deletions

File tree

‎docs/client/transports.md‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -55,10 +55,11 @@ Pass `max_sse_event_size` when a server sends a large tool result or notificatio
5555
```
5656

5757
The default is 16 MiB per event, measured in bytes before the event is parsed. The limit applies to
58-
POST responses, the GET stream, and resumed streams. If an event exceeds it, the request fails with
59-
an error naming the limit. Set `max_sse_event_size=None` to disable the cap when you trust the server
60-
and need larger events. JSON responses are unaffected. If you use `ClientSessionGroup`, set the same
61-
option on `StreamableHttpParameters`.
58+
POST responses, the GET stream, and resumed streams. An oversized event in a POST response or resumed
59+
stream fails that request with an error naming the limit. On the background GET stream, the client logs
60+
the error and retries the stream. Set `max_sse_event_size=None` to disable the cap when you trust the
61+
server and need larger events. JSON responses are unaffected. If you use `ClientSessionGroup`, set the
62+
same option on `StreamableHttpParameters`.
6263

6364
!!! warning
6465
`streamable_http_client` used to take `headers=` and `timeout=` directly. It does not any more:

‎src/mcp/client/streamable_http.py‎

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -259,9 +259,6 @@ async def handle_get_stream(self, client: httpx2.AsyncClient, read_stream_writer
259259
# Stream ended normally (server closed) - reset attempt counter
260260
attempt = 0
261261

262-
except httpx2.SSEError:
263-
logger.exception("GET SSE stream failed")
264-
return
265262
except Exception:
266263
logger.debug("GET stream error", exc_info=True)
267264
attempt += 1
@@ -564,11 +561,14 @@ async def _handle_reconnection(
564561
headers = self._prepare_headers()
565562
headers[LAST_EVENT_ID] = last_event_id
566563

564+
is_sse_response = False
567565
try:
568566
async with sse_within_origin(
569567
ctx.client, self.url, headers=headers, max_event_size=self.max_sse_event_size
570568
) as event_source:
571569
event_source.response.raise_for_status()
570+
content_type = event_source.response.headers.get("content-type", "").partition(";")[0]
571+
is_sse_response = content_type.strip().lower() == "text/event-stream"
572572
logger.info("Reconnected to SSE stream")
573573

574574
# Track for potential further reconnection
@@ -595,9 +595,12 @@ async def _handle_reconnection(
595595
logger.info("SSE stream disconnected, reconnecting...")
596596
await self._handle_reconnection(ctx, reconnect_last_event_id, reconnect_retry_ms, 0)
597597
except httpx2.SSEError as exc:
598-
await self._resolve_abandoned_request(
599-
ctx.read_stream_writer, original_request_id, f"SSE stream failed: {exc}"
600-
)
598+
if is_sse_response:
599+
await self._resolve_abandoned_request(
600+
ctx.read_stream_writer, original_request_id, f"SSE stream failed: {exc}"
601+
)
602+
else:
603+
await self._handle_reconnection(ctx, last_event_id, retry_interval_ms, attempt + 1)
601604
except Exception as e: # pragma: no cover
602605
logger.debug(f"Reconnection failed: {e}")
603606
# Try to reconnect again if we still have an event ID

‎tests/client/test_streamable_http.py‎

Lines changed: 83 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@
77

88
import base64
99
import json
10-
import logging
1110
from collections.abc import AsyncIterator, Callable, Mapping
1211
from typing import Any
1312

@@ -116,23 +115,23 @@ async def call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) ->
116115
result = await client.call_tool("small", {})
117116

118117
assert exc_info.value.error.code == CONNECTION_CLOSED
119-
assert exc_info.value.error.message == snapshot(
120-
"SSE stream failed: Server-sent event exceeded the 1024 byte limit."
121-
)
118+
# httpx2 owns the error detail after this prefix.
119+
assert exc_info.value.error.message.startswith("SSE stream failed: ")
122120
assert result.content == [TextContent(text="ok")]
123121
assert calls == ["large", "small"]
124122

125123

126124
@pytest.mark.anyio
127125
async def test_configured_sse_limit_accepts_a_large_get_notification() -> None:
128-
"""SDK-defined: the transport applies its configured SSE byte limit to the standalone GET stream."""
126+
"""SDK-defined: a configured limit accepts a large standalone GET event.
127+
128+
The raw peer forces the notification onto GET, a transport detail hidden by the typed client.
129+
"""
129130
payload = "x" * (2 * 1024 * 1024)
130131
notification = json.dumps({"jsonrpc": "2.0", "method": "notifications/message", "params": {"data": payload}})
131-
gets: list[httpx2.Request] = []
132132

133133
def handler(request: httpx2.Request) -> httpx2.Response:
134134
if request.method == "GET":
135-
gets.append(request)
136135
return httpx2.Response(
137136
200, content=f"data: {notification}\n\n".encode(), headers={"content-type": "text/event-stream"}
138137
)
@@ -162,31 +161,25 @@ def handler(request: httpx2.Request) -> httpx2.Response:
162161
assert isinstance(received.message, JSONRPCNotification)
163162
assert received.message.method == "notifications/message"
164163
assert received.message.params == {"data": payload}
165-
assert len(gets) == 1
166164

167165

168166
@pytest.mark.anyio
169-
async def test_oversized_get_event_stops_reconnection_and_does_not_block_posts() -> None:
170-
"""SDK-defined: a deterministic GET size error stops that stream while later POSTs still work."""
171-
failed = anyio.Event()
172-
gets: list[httpx2.Request] = []
173-
errors: list[str] = []
167+
async def test_oversized_get_event_reconnects_and_delivers_later_messages() -> None:
168+
"""SDK-defined: a bad GET event does not prevent later server messages or POSTs.
174169
175-
class ErrorSignal(logging.Handler):
176-
def emit(self, record: logging.LogRecord) -> None:
177-
errors.append(record.getMessage())
178-
failed.set()
170+
The raw peer forces a bad GET followed by a fresh GET stream, which the typed client cannot schedule.
171+
"""
172+
gets: list[httpx2.Request] = []
179173

180174
def handler(request: httpx2.Request) -> httpx2.Response:
181175
if request.method == "GET":
182176
gets.append(request)
183-
return httpx2.Response(
184-
200,
185-
content=b'data: {"jsonrpc":"2.0","method":"notifications/message","params":{"data":"'
186-
+ b"x" * 2048
187-
+ b'"}}\n\n',
188-
headers={"content-type": "text/event-stream"},
189-
)
177+
if len(gets) == 1:
178+
content = b'data: {"jsonrpc":"2.0","method":"notifications/message","params":{"data":"'
179+
content += b"x" * 2048 + b'"}}\n\n'
180+
else:
181+
content = b'data: {"jsonrpc":"2.0","method":"notifications/message","params":{"data":"ok"}}\n\n'
182+
return httpx2.Response(200, content=content, headers={"content-type": "text/event-stream"})
190183
body = json.loads(request.content)
191184
if body.get("method") == "initialize":
192185
return httpx2.Response(
@@ -196,32 +189,27 @@ def handler(request: httpx2.Request) -> httpx2.Response:
196189
return httpx2.Response(200, json={"jsonrpc": "2.0", "id": body["id"], "result": {}})
197190
return httpx2.Response(202)
198191

199-
error_signal = ErrorSignal()
200-
error_signal.setLevel(logging.ERROR)
201-
transport_logger = logging.getLogger("mcp.client.streamable_http")
202-
transport_logger.addHandler(error_signal)
203-
try:
204-
with anyio.fail_after(5):
205-
async with (
206-
httpx2.AsyncClient(transport=httpx2.MockTransport(handler)) as http,
207-
streamable_http_client(
208-
"http://test/mcp", http_client=http, terminate_on_close=False, max_sse_event_size=1024
209-
) as (read, write),
210-
):
211-
await write.send(SessionMessage(JSONRPCRequest(jsonrpc="2.0", id=1, method="initialize", params={})))
212-
await read.receive()
213-
await write.send(SessionMessage(JSONRPCNotification(jsonrpc="2.0", method="notifications/initialized")))
214-
await failed.wait()
215-
await write.send(SessionMessage(JSONRPCRequest(jsonrpc="2.0", id=2, method="tools/list", params={})))
216-
reply = await read.receive()
217-
finally:
218-
transport_logger.removeHandler(error_signal)
192+
with anyio.fail_after(5):
193+
async with (
194+
httpx2.AsyncClient(transport=httpx2.MockTransport(handler)) as http,
195+
streamable_http_client(
196+
"http://test/mcp", http_client=http, terminate_on_close=False, max_sse_event_size=1024
197+
) as (read, write),
198+
):
199+
await write.send(SessionMessage(JSONRPCRequest(jsonrpc="2.0", id=1, method="initialize", params={})))
200+
await read.receive()
201+
await write.send(SessionMessage(JSONRPCNotification(jsonrpc="2.0", method="notifications/initialized")))
202+
notification = await read.receive()
203+
await write.send(SessionMessage(JSONRPCRequest(jsonrpc="2.0", id=2, method="tools/list", params={})))
204+
reply = await read.receive()
219205

206+
assert isinstance(notification, SessionMessage)
207+
assert isinstance(notification.message, JSONRPCNotification)
208+
assert notification.message.params == {"data": "ok"}
220209
assert isinstance(reply, SessionMessage)
221210
assert isinstance(reply.message, JSONRPCResponse)
222211
assert reply.message.id == 2
223-
assert len(gets) == 1
224-
assert errors == ["GET SSE stream failed"]
212+
assert len(gets) >= 2
225213

226214

227215
@pytest.mark.anyio
@@ -236,7 +224,10 @@ async def test_streamable_http_rejects_nonpositive_sse_event_limits(limit: int)
236224

237225
@pytest.mark.anyio
238226
async def test_none_disables_the_sse_event_limit() -> None:
239-
"""SDK-defined: opting out accepts an SSE response larger than the transport's 16 MiB default."""
227+
"""SDK-defined: opting out accepts a POST SSE response larger than 16 MiB.
228+
229+
The raw peer forces SSE for the request; the typed server can choose a JSON response instead.
230+
"""
240231
payload = "x" * (16 * 1024 * 1024 + 1)
241232

242233
def handler(request: httpx2.Request) -> httpx2.Response:
@@ -262,7 +253,10 @@ def handler(request: httpx2.Request) -> httpx2.Response:
262253

263254
@pytest.mark.anyio
264255
async def test_configured_sse_limit_fails_an_oversized_resumption_event() -> None:
265-
"""SDK-defined: a resumed request gets a size error rather than waiting for an unusable event."""
256+
"""SDK-defined: an oversized resumption event fails its request.
257+
258+
The raw peer sends an oversized replay regardless of token state, which a typed server cannot force.
259+
"""
266260
seen: list[str] = []
267261

268262
def handler(request: httpx2.Request) -> httpx2.Response:
@@ -289,7 +283,8 @@ def handler(request: httpx2.Request) -> httpx2.Response:
289283
assert isinstance(reply, SessionMessage)
290284
assert isinstance(reply.message, JSONRPCError)
291285
assert reply.message.error.code == CONNECTION_CLOSED
292-
assert reply.message.error.message == snapshot("SSE stream failed: Server-sent event exceeded the 1024 byte limit.")
286+
# httpx2 owns the error detail after this prefix.
287+
assert reply.message.error.message.startswith("SSE stream failed: ")
293288
assert seen == ["GET"]
294289

295290

@@ -325,10 +320,48 @@ def handler(request: httpx2.Request) -> httpx2.Response:
325320
assert isinstance(reply, SessionMessage)
326321
assert isinstance(reply.message, JSONRPCError)
327322
assert reply.message.error.code == CONNECTION_CLOSED
328-
assert reply.message.error.message == snapshot("SSE stream failed: Server-sent event exceeded the 1024 byte limit.")
323+
# httpx2 owns the error detail after this prefix.
324+
assert reply.message.error.message.startswith("SSE stream failed: ")
329325
assert seen == ["POST", "GET"]
330326

331327

328+
@pytest.mark.anyio
329+
async def test_non_sse_reconnection_response_retries_before_failing_the_request() -> None:
330+
"""SDK-defined: a temporary non-SSE reconnect response does not discard the request.
331+
332+
The raw peer forces a non-SSE GET followed by a valid replay, which the typed server cannot produce.
333+
"""
334+
seen: list[str] = []
335+
336+
def handler(request: httpx2.Request) -> httpx2.Response:
337+
seen.append(request.method)
338+
if request.method == "POST":
339+
return httpx2.Response(
340+
200, content=b"id: evt-1\nretry: 0\n\n", headers={"content-type": "text/event-stream"}
341+
)
342+
assert request.headers["last-event-id"] == "evt-1"
343+
if len(seen) == 2:
344+
return httpx2.Response(200, content=b"temporary proxy response", headers={"content-type": "text/plain"})
345+
return httpx2.Response(
346+
200,
347+
content=b'data: {"jsonrpc":"2.0","id":1,"result":{}}\n\n',
348+
headers={"content-type": "text/event-stream"},
349+
)
350+
351+
with anyio.fail_after(5):
352+
async with (
353+
httpx2.AsyncClient(transport=httpx2.MockTransport(handler)) as http,
354+
streamable_http_client("http://test/mcp", http_client=http, max_sse_event_size=1024) as (read, write),
355+
):
356+
await write.send(SessionMessage(JSONRPCRequest(jsonrpc="2.0", id=1, method="tools/call", params={})))
357+
reply = await read.receive()
358+
359+
assert isinstance(reply, SessionMessage)
360+
assert isinstance(reply.message, JSONRPCResponse)
361+
assert reply.message.id == 1
362+
assert seen == ["POST", "GET", "GET"]
363+
364+
332365
@pytest.mark.parametrize(
333366
("raw", "expected", "wrapped"),
334367
[

0 commit comments

Comments
 (0)