77
88import base64
99import json
10- import logging
1110from collections .abc import AsyncIterator , Callable , Mapping
1211from 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
127125async 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
238226async 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
264255async 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\n retry: 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