@@ -799,6 +799,70 @@ describe('StreamableHTTPClientTransport', () => {
799799 expect ( fetchMock ) . toHaveBeenCalledTimes ( 1 ) ;
800800 expect ( fetchMock . mock . calls [ 0 ] [ 1 ] ?. method ) . toBe ( 'POST' ) ;
801801 } ) ;
802+
803+ it ( 'should reconnect a POST-initiated stream after receiving a priming event' , async ( ) => {
804+ // ARRANGE
805+ transport = new StreamableHTTPClientTransport ( new URL ( 'http://localhost:1234/mcp' ) , {
806+ reconnectionOptions : {
807+ initialReconnectionDelay : 10 ,
808+ maxRetries : 1 ,
809+ maxReconnectionDelay : 1000 ,
810+ reconnectionDelayGrowFactor : 1
811+ }
812+ } ) ;
813+
814+ const errorSpy = vi . fn ( ) ;
815+ transport . onerror = errorSpy ;
816+
817+ // Create a stream that sends a priming event (with ID) then closes
818+ const streamWithPrimingEvent = new ReadableStream ( {
819+ start ( controller ) {
820+ // Send a priming event with an ID - this enables reconnection
821+ controller . enqueue (
822+ new TextEncoder ( ) . encode ( 'id: event-123\ndata: {"jsonrpc":"2.0","method":"notifications/message","params":{}}\n\n' )
823+ ) ;
824+ // Then close the stream (simulating server disconnect)
825+ controller . close ( ) ;
826+ }
827+ } ) ;
828+
829+ const fetchMock = global . fetch as Mock ;
830+ // First call: POST returns streaming response with priming event
831+ fetchMock . mockResolvedValueOnce ( {
832+ ok : true ,
833+ status : 200 ,
834+ headers : new Headers ( { 'content-type' : 'text/event-stream' } ) ,
835+ body : streamWithPrimingEvent
836+ } ) ;
837+ // Second call: GET reconnection - return 405 to stop further reconnection
838+ fetchMock . mockResolvedValueOnce ( {
839+ ok : false ,
840+ status : 405 ,
841+ headers : new Headers ( )
842+ } ) ;
843+
844+ const requestMessage : JSONRPCRequest = {
845+ jsonrpc : '2.0' ,
846+ method : 'long_running_tool' ,
847+ id : 'request-1' ,
848+ params : { }
849+ } ;
850+
851+ // ACT
852+ await transport . start ( ) ;
853+ await transport . send ( requestMessage ) ;
854+ // Wait for stream to process and reconnection to be scheduled
855+ await vi . advanceTimersByTimeAsync ( 50 ) ;
856+
857+ // ASSERT
858+ // THE KEY ASSERTION: Fetch was called TWICE - POST then GET reconnection
859+ expect ( fetchMock ) . toHaveBeenCalledTimes ( 2 ) ;
860+ expect ( fetchMock . mock . calls [ 0 ] [ 1 ] ?. method ) . toBe ( 'POST' ) ;
861+ expect ( fetchMock . mock . calls [ 1 ] [ 1 ] ?. method ) . toBe ( 'GET' ) ;
862+ // Verify Last-Event-ID header was sent for reconnection
863+ const reconnectHeaders = fetchMock . mock . calls [ 1 ] [ 1 ] ?. headers as Headers ;
864+ expect ( reconnectHeaders . get ( 'last-event-id' ) ) . toBe ( 'event-123' ) ;
865+ } ) ;
802866 } ) ;
803867
804868 it ( 'invalidates all credentials on InvalidClientError during auth' , async ( ) => {
@@ -1102,6 +1166,148 @@ describe('StreamableHTTPClientTransport', () => {
11021166 } ) ;
11031167 } ) ;
11041168
1169+ describe ( 'SSE retry field handling' , ( ) => {
1170+ beforeEach ( ( ) => {
1171+ vi . useFakeTimers ( ) ;
1172+ ( global . fetch as Mock ) . mockReset ( ) ;
1173+ } ) ;
1174+ afterEach ( ( ) => vi . useRealTimers ( ) ) ;
1175+
1176+ it ( 'should use server-provided retry value for reconnection delay' , async ( ) => {
1177+ transport = new StreamableHTTPClientTransport ( new URL ( 'http://localhost:1234/mcp' ) , {
1178+ reconnectionOptions : {
1179+ initialReconnectionDelay : 100 ,
1180+ maxReconnectionDelay : 5000 ,
1181+ reconnectionDelayGrowFactor : 2 ,
1182+ maxRetries : 3
1183+ }
1184+ } ) ;
1185+
1186+ // Create a stream that sends a retry field
1187+ const encoder = new TextEncoder ( ) ;
1188+ const stream = new ReadableStream ( {
1189+ start ( controller ) {
1190+ // Send SSE event with retry field
1191+ const event =
1192+ 'retry: 3000\nevent: message\nid: evt-1\ndata: {"jsonrpc": "2.0", "method": "notification", "params": {}}\n\n' ;
1193+ controller . enqueue ( encoder . encode ( event ) ) ;
1194+ // Close stream to trigger reconnection
1195+ controller . close ( ) ;
1196+ }
1197+ } ) ;
1198+
1199+ const fetchMock = global . fetch as Mock ;
1200+ fetchMock . mockResolvedValueOnce ( {
1201+ ok : true ,
1202+ status : 200 ,
1203+ headers : new Headers ( { 'content-type' : 'text/event-stream' } ) ,
1204+ body : stream
1205+ } ) ;
1206+
1207+ // Second request for reconnection
1208+ fetchMock . mockResolvedValueOnce ( {
1209+ ok : true ,
1210+ status : 200 ,
1211+ headers : new Headers ( { 'content-type' : 'text/event-stream' } ) ,
1212+ body : new ReadableStream ( )
1213+ } ) ;
1214+
1215+ await transport . start ( ) ;
1216+ await transport [ '_startOrAuthSse' ] ( { } ) ;
1217+
1218+ // Wait for stream to close and reconnection to be scheduled
1219+ await vi . advanceTimersByTimeAsync ( 100 ) ;
1220+
1221+ // Verify the server retry value was captured
1222+ const transportInternal = transport as unknown as { _serverRetryMs ?: number } ;
1223+ expect ( transportInternal . _serverRetryMs ) . toBe ( 3000 ) ;
1224+
1225+ // Verify the delay calculation uses server retry value
1226+ const getDelay = transport [ '_getNextReconnectionDelay' ] . bind ( transport ) ;
1227+ expect ( getDelay ( 0 ) ) . toBe ( 3000 ) ; // Should use server value, not 100ms initial
1228+ expect ( getDelay ( 5 ) ) . toBe ( 3000 ) ; // Should still use server value for any attempt
1229+ } ) ;
1230+
1231+ it ( 'should fall back to exponential backoff when no server retry value' , ( ) => {
1232+ transport = new StreamableHTTPClientTransport ( new URL ( 'http://localhost:1234/mcp' ) , {
1233+ reconnectionOptions : {
1234+ initialReconnectionDelay : 100 ,
1235+ maxReconnectionDelay : 5000 ,
1236+ reconnectionDelayGrowFactor : 2 ,
1237+ maxRetries : 3
1238+ }
1239+ } ) ;
1240+
1241+ // Without any SSE stream, _serverRetryMs should be undefined
1242+ const transportInternal = transport as unknown as { _serverRetryMs ?: number } ;
1243+ expect ( transportInternal . _serverRetryMs ) . toBeUndefined ( ) ;
1244+
1245+ // Should use exponential backoff
1246+ const getDelay = transport [ '_getNextReconnectionDelay' ] . bind ( transport ) ;
1247+ expect ( getDelay ( 0 ) ) . toBe ( 100 ) ; // 100 * 2^0
1248+ expect ( getDelay ( 1 ) ) . toBe ( 200 ) ; // 100 * 2^1
1249+ expect ( getDelay ( 2 ) ) . toBe ( 400 ) ; // 100 * 2^2
1250+ expect ( getDelay ( 10 ) ) . toBe ( 5000 ) ; // capped at max
1251+ } ) ;
1252+
1253+ it ( 'should reconnect on graceful stream close' , async ( ) => {
1254+ transport = new StreamableHTTPClientTransport ( new URL ( 'http://localhost:1234/mcp' ) , {
1255+ reconnectionOptions : {
1256+ initialReconnectionDelay : 10 ,
1257+ maxReconnectionDelay : 1000 ,
1258+ reconnectionDelayGrowFactor : 1 ,
1259+ maxRetries : 1
1260+ }
1261+ } ) ;
1262+
1263+ // Create a stream that closes gracefully after sending an event with ID
1264+ const encoder = new TextEncoder ( ) ;
1265+ const stream = new ReadableStream ( {
1266+ start ( controller ) {
1267+ // Send priming event with ID and retry field
1268+ const event = 'id: evt-1\nretry: 100\ndata: \n\n' ;
1269+ controller . enqueue ( encoder . encode ( event ) ) ;
1270+ // Graceful close
1271+ controller . close ( ) ;
1272+ }
1273+ } ) ;
1274+
1275+ const fetchMock = global . fetch as Mock ;
1276+ fetchMock . mockResolvedValueOnce ( {
1277+ ok : true ,
1278+ status : 200 ,
1279+ headers : new Headers ( { 'content-type' : 'text/event-stream' } ) ,
1280+ body : stream
1281+ } ) ;
1282+
1283+ // Second request for reconnection
1284+ fetchMock . mockResolvedValueOnce ( {
1285+ ok : true ,
1286+ status : 200 ,
1287+ headers : new Headers ( { 'content-type' : 'text/event-stream' } ) ,
1288+ body : new ReadableStream ( )
1289+ } ) ;
1290+
1291+ await transport . start ( ) ;
1292+ await transport [ '_startOrAuthSse' ] ( { } ) ;
1293+
1294+ // Wait for stream to process and close
1295+ await vi . advanceTimersByTimeAsync ( 50 ) ;
1296+
1297+ // Wait for reconnection delay (100ms from retry field)
1298+ await vi . advanceTimersByTimeAsync ( 150 ) ;
1299+
1300+ // Should have attempted reconnection
1301+ expect ( fetchMock ) . toHaveBeenCalledTimes ( 2 ) ;
1302+ expect ( fetchMock . mock . calls [ 0 ] [ 1 ] ?. method ) . toBe ( 'GET' ) ;
1303+ expect ( fetchMock . mock . calls [ 1 ] [ 1 ] ?. method ) . toBe ( 'GET' ) ;
1304+
1305+ // Second call should include Last-Event-ID
1306+ const secondCallHeaders = fetchMock . mock . calls [ 1 ] [ 1 ] ?. headers ;
1307+ expect ( secondCallHeaders ?. get ( 'last-event-id' ) ) . toBe ( 'evt-1' ) ;
1308+ } ) ;
1309+ } ) ;
1310+
11051311 describe ( 'prevent infinite recursion when server returns 401 after successful auth' , ( ) => {
11061312 it ( 'should throw error when server returns 401 after successful auth' , async ( ) => {
11071313 const message : JSONRPCMessage = {
0 commit comments