Skip to content

Commit 2c9bb5d

Browse files
Copilotstephentoub
andauthored
Fix off-by-one error in reconnection attempts (#1356)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com>
1 parent 5c68dec commit 2c9bb5d

2 files changed

Lines changed: 84 additions & 1 deletion

File tree

src/ModelContextProtocol.Core/Client/StreamableHttpClientSessionTransport.cs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -228,7 +228,10 @@ await SendGetSseRequestWithRetriesAsync(
228228
SseStreamState state,
229229
CancellationToken cancellationToken)
230230
{
231-
int attempt = 0;
231+
// When LastEventId is null, the first attempt is the initial GET SSE connection (not a reconnection),
232+
// so we start at -1 to avoid counting it against MaxReconnectionAttempts.
233+
// When LastEventId is already set, all attempts are true reconnections, so we start at 0.
234+
int attempt = state.LastEventId is null ? -1 : 0;
232235

233236
// Delay before first attempt if we're reconnecting (have a Last-Event-ID)
234237
bool shouldDelay = state.LastEventId is not null;

tests/ModelContextProtocol.Tests/Transport/HttpClientTransportTests.cs

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
using ModelContextProtocol.Protocol;
33
using ModelContextProtocol.Tests.Utils;
44
using System.Net;
5+
using System.Text;
56

67
namespace ModelContextProtocol.Tests.Transport;
78

@@ -246,4 +247,83 @@ public async Task DisposeAsync_Should_Dispose_Resources()
246247
var transportBase = Assert.IsAssignableFrom<TransportBase>(session);
247248
Assert.False(transportBase.IsConnected);
248249
}
250+
251+
[Fact]
252+
public async Task StreamableHttp_InitialGetSseConnection_DoesNotCountAgainstMaxReconnectionAttempts()
253+
{
254+
// Arrange: The initial GET SSE connection (with no Last-Event-ID) is the initial connection,
255+
// not a reconnection. It should not count against MaxReconnectionAttempts.
256+
// With MaxReconnectionAttempts=2, we expect 1 initial + 2 reconnection = 3 total GET requests.
257+
const int MaxReconnectionAttempts = 2;
258+
259+
var getRequestCount = 0;
260+
var allGetRequestsDone = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
261+
262+
var options = new HttpClientTransportOptions
263+
{
264+
Endpoint = new Uri("http://localhost:8080"),
265+
TransportMode = HttpTransportMode.StreamableHttp,
266+
MaxReconnectionAttempts = MaxReconnectionAttempts,
267+
DefaultReconnectionInterval = TimeSpan.FromMilliseconds(1),
268+
};
269+
270+
using var mockHttpHandler = new MockHttpHandler();
271+
using var httpClient = new HttpClient(mockHttpHandler);
272+
await using var transport = new HttpClientTransport(options, httpClient, LoggerFactory);
273+
274+
mockHttpHandler.RequestHandler = (request) =>
275+
{
276+
if (request.Method == HttpMethod.Post)
277+
{
278+
// Return a successful initialize response with a session-id header.
279+
// This triggers ReceiveUnsolicitedMessagesAsync which starts the GET SSE stream.
280+
var response = new HttpResponseMessage
281+
{
282+
StatusCode = HttpStatusCode.OK,
283+
Content = new StringContent(
284+
"""{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-03-26","capabilities":{},"serverInfo":{"name":"TestServer","version":"1.0.0"}}}""",
285+
Encoding.UTF8,
286+
"application/json"),
287+
};
288+
response.Headers.Add("Mcp-Session-Id", "test-session");
289+
return Task.FromResult(response);
290+
}
291+
292+
if (request.Method == HttpMethod.Get)
293+
{
294+
// Return 500 for all GET SSE requests to force the retry loop to exhaust all attempts.
295+
var count = Interlocked.Increment(ref getRequestCount);
296+
if (count == 1 + MaxReconnectionAttempts)
297+
{
298+
allGetRequestsDone.TrySetResult(true);
299+
}
300+
return Task.FromResult(new HttpResponseMessage
301+
{
302+
StatusCode = HttpStatusCode.InternalServerError,
303+
});
304+
}
305+
306+
if (request.Method == HttpMethod.Delete)
307+
{
308+
return Task.FromResult(new HttpResponseMessage
309+
{
310+
StatusCode = HttpStatusCode.OK,
311+
});
312+
}
313+
314+
throw new InvalidOperationException($"Unexpected request: {request.Method}");
315+
};
316+
317+
// Act - Connect and send the initialize request, which starts the background GET SSE task.
318+
await using var session = await transport.ConnectAsync(TestContext.Current.CancellationToken);
319+
await session.SendMessageAsync(
320+
new JsonRpcRequest { Method = RequestMethods.Initialize, Id = new RequestId(1) },
321+
TestContext.Current.CancellationToken);
322+
323+
// Wait for all expected GET requests to be made before disposing.
324+
await allGetRequestsDone.Task.WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken);
325+
326+
// Assert - Total GET requests = 1 initial connection + MaxReconnectionAttempts reconnections.
327+
Assert.Equal(1 + MaxReconnectionAttempts, getRequestCount);
328+
}
249329
}

0 commit comments

Comments
 (0)