11from unittest .mock import AsyncMock , Mock
22
3+ import anyio
34import pytest
45
56from mcp import types
67from mcp .server .lowlevel .server import Server
78from mcp .server .session import ServerSession
9+ from mcp .shared .message import SessionMessage
810from mcp .shared .session import RequestResponder
911
1012
1113@pytest .mark .anyio
1214async def test_exception_handling_with_raise_exceptions_true ():
13- """Test that exceptions are re-raised when raise_exceptions=True"""
15+ """Transport exceptions are re-raised when raise_exceptions=True. """
1416 server = Server ("test-server" )
1517 session = Mock (spec = ServerSession )
16- session .send_log_message = AsyncMock ()
1718
1819 test_exception = RuntimeError ("Test error" )
1920
2021 with pytest .raises (RuntimeError , match = "Test error" ):
2122 await server ._handle_message (test_exception , session , {}, raise_exceptions = True )
2223
23- session .send_log_message .assert_called_once ()
24-
2524
2625@pytest .mark .anyio
27- @pytest .mark .parametrize (
28- "exception_class,message" ,
29- [
30- (ValueError , "Test validation error" ),
31- (RuntimeError , "Test runtime error" ),
32- (KeyError , "Test key error" ),
33- (Exception , "Basic error" ),
34- ],
35- )
36- async def test_exception_handling_with_raise_exceptions_false (exception_class : type [Exception ], message : str ):
37- """Test that exceptions are logged when raise_exceptions=False"""
26+ async def test_exception_handling_with_raise_exceptions_false ():
27+ """Transport exceptions are logged locally but not sent to the client.
28+
29+ The transport that reported the error is likely broken; writing back
30+ through it races with stream closure (#1967, #2064). The TypeScript,
31+ Go, and C# SDKs all log locally only.
32+ """
3833 server = Server ("test-server" )
3934 session = Mock (spec = ServerSession )
4035 session .send_log_message = AsyncMock ()
4136
42- test_exception = exception_class (message )
43-
44- await server ._handle_message (test_exception , session , {}, raise_exceptions = False )
45-
46- # Should send log message
47- session .send_log_message .assert_called_once ()
48- call_args = session .send_log_message .call_args
37+ await server ._handle_message (RuntimeError ("Test error" ), session , {}, raise_exceptions = False )
4938
50- assert call_args .kwargs ["level" ] == "error"
51- assert call_args .kwargs ["data" ] == "Internal Server Error"
52- assert call_args .kwargs ["logger" ] == "mcp.server.exception_handler"
39+ session .send_log_message .assert_not_called ()
5340
5441
5542@pytest .mark .anyio
@@ -72,3 +59,48 @@ async def test_normal_message_handling_not_affected():
7259
7360 # Verify _handle_request was called
7461 server ._handle_request .assert_called_once ()
62+
63+
64+ @pytest .mark .anyio
65+ async def test_server_run_exits_cleanly_when_transport_yields_exception_then_closes ():
66+ """Regression test for #1967 / #2064.
67+
68+ Exercises the real Server.run() path with real memory streams, reproducing
69+ what happens in stateless streamable HTTP when a POST handler throws:
70+
71+ 1. Transport yields an Exception into the read stream
72+ (streamable_http.py does this in its broad POST-handler except).
73+ 2. Transport closes the read stream (terminate() in stateless mode).
74+ 3. _receive_loop exits its `async with read_stream, write_stream:` block,
75+ closing the write stream.
76+ 4. Meanwhile _handle_message(exc) was spawned via tg.start_soon and runs
77+ after the write stream is closed.
78+
79+ Before the fix, _handle_message tried to send_log_message through the
80+ closed write stream, raising ClosedResourceError inside the TaskGroup
81+ and crashing server.run(). After the fix, it only logs locally.
82+ """
83+ server = Server ("test-server" )
84+
85+ read_send , read_recv = anyio .create_memory_object_stream [SessionMessage | Exception ](1 )
86+ # Zero-buffer on the write stream forces send() to block until received.
87+ # With no receiver, a send() sits blocked until _receive_loop exits its
88+ # `async with self._read_stream, self._write_stream:` block and closes the
89+ # stream, at which point the blocked send raises ClosedResourceError.
90+ # This deterministically reproduces the race without sleeps.
91+ write_send , write_recv = anyio .create_memory_object_stream [SessionMessage ](0 )
92+
93+ # What the streamable HTTP transport does: push the exception, then close.
94+ read_send .send_nowait (RuntimeError ("simulated transport error" ))
95+ read_send .close ()
96+
97+ with anyio .fail_after (5 ):
98+ # stateless=True so server.run doesn't wait for initialize handshake.
99+ # Before this fix, this raised ExceptionGroup(ClosedResourceError).
100+ await server .run (read_recv , write_send , server .create_initialization_options (), stateless = True )
101+
102+ # write_send was closed inside _receive_loop's `async with`; receive_nowait
103+ # raises EndOfStream iff the buffer is empty (i.e., server wrote nothing).
104+ with pytest .raises (anyio .EndOfStream ):
105+ write_recv .receive_nowait ()
106+ write_recv .close ()
0 commit comments