@@ -20,11 +20,12 @@ internal sealed partial class StreamableHttpClientSessionTransport : TransportBa
2020
2121 private readonly McpHttpClient _httpClient ;
2222 private readonly HttpClientTransportOptions _options ;
23- private readonly CancellationTokenSource _connectionCts ;
23+ private readonly CancellationTokenSource _connectionCts = new ( ) ;
2424 private readonly ILogger _logger ;
2525
2626 private string ? _negotiatedProtocolVersion ;
2727 private Task ? _getReceiveTask ;
28+ private volatile TransportClosedException ? _disconnectError ;
2829
2930 private readonly SemaphoreSlim _disposeLock = new ( 1 , 1 ) ;
3031 private bool _disposed ;
@@ -42,7 +43,6 @@ public StreamableHttpClientSessionTransport(
4243
4344 _options = transportOptions ;
4445 _httpClient = httpClient ;
45- _connectionCts = new CancellationTokenSource ( ) ;
4646 _logger = ( ILogger ? ) loggerFactory ? . CreateLogger < HttpClientTransport > ( ) ?? NullLogger . Instance ;
4747
4848 // We connect with the initialization request with the MCP transport. This means that any errors won't be observed
@@ -96,6 +96,13 @@ internal async Task<HttpResponseMessage> SendHttpRequestAsync(JsonRpcMessage mes
9696 // We'll let the caller decide whether to throw or fall back given an unsuccessful response.
9797 if ( ! response . IsSuccessStatusCode )
9898 {
99+ // Per the MCP spec, a 404 response to a request containing an Mcp-Session-Id
100+ // indicates the session has ended. Signal completion so McpClient.Completion resolves.
101+ if ( response . StatusCode == HttpStatusCode . NotFound && SessionId is not null )
102+ {
103+ SetSessionExpired ( ) ;
104+ }
105+
99106 return response ;
100107 }
101108
@@ -184,18 +191,16 @@ public override async ValueTask DisposeAsync()
184191 {
185192 LogTransportShutdownFailed ( Name , ex ) ;
186193 }
187- finally
188- {
189- _connectionCts . Dispose ( ) ;
190- }
191194 }
192195 finally
193196 {
194197 // If we're auto-detecting the transport and failed to connect, leave the message Channel open for the SSE transport.
195198 // This class isn't directly exposed to public callers, so we don't have to worry about changing the _state in this case.
196199 if ( _options . TransportMode is not HttpTransportMode . AutoDetect || _getReceiveTask is not null )
197200 {
198- SetDisconnected ( ) ;
201+ // _disconnectError is set when the server returns 404 indicating session expiry.
202+ // When null, this is a graceful client-initiated closure (no error).
203+ SetDisconnected ( _disconnectError ?? new TransportClosedException ( new HttpClientCompletionDetails ( ) ) ) ;
199204 }
200205 }
201206 }
@@ -204,8 +209,8 @@ private async Task ReceiveUnsolicitedMessagesAsync()
204209 {
205210 var state = new SseStreamState ( ) ;
206211
207- // Continuously receive unsolicited messages until canceled
208- while ( ! _connectionCts . Token . IsCancellationRequested )
212+ // Continuously receive unsolicited messages until canceled or disconnected
213+ while ( ! _connectionCts . Token . IsCancellationRequested && IsConnected )
209214 {
210215 await SendGetSseRequestWithRetriesAsync (
211216 relatedRpcRequest : null ,
@@ -285,6 +290,13 @@ await SendGetSseRequestWithRetriesAsync(
285290
286291 if ( ! response . IsSuccessStatusCode )
287292 {
293+ // Per the MCP spec, a 404 response to a request containing an Mcp-Session-Id
294+ // indicates the session has ended. Signal completion so McpClient.Completion resolves.
295+ if ( response . StatusCode == HttpStatusCode . NotFound && SessionId is not null )
296+ {
297+ SetSessionExpired ( ) ;
298+ }
299+
288300 // If the server could be reached but returned a non-success status code,
289301 // retrying likely won't change that.
290302 return null ;
@@ -474,4 +486,23 @@ private static TimeSpan ElapsedSince(long stopwatchTimestamp)
474486 return TimeSpan . FromSeconds ( ( double ) ( Stopwatch . GetTimestamp ( ) - stopwatchTimestamp ) / Stopwatch . Frequency ) ;
475487#endif
476488 }
489+
490+ private void SetSessionExpired ( )
491+ {
492+ // Store the error before canceling so DisposeAsync can use it if it races us, especially
493+ // after the call to Cancel below, to invoke SetDisconnected.
494+ _disconnectError = new TransportClosedException ( new HttpClientCompletionDetails
495+ {
496+ HttpStatusCode = HttpStatusCode . NotFound ,
497+ Exception = new McpException (
498+ "The server returned HTTP 404 for a request with an Mcp-Session-Id, indicating the session has expired. " +
499+ "To continue, create a new client session or call ResumeSessionAsync with a new connection." ) ,
500+ } ) ;
501+
502+ // Cancel to unblock any in-flight operations (e.g., SSE stream reads in
503+ // SendGetSseRequestWithRetriesAsync) that are waiting on _connectionCts.Token.
504+ _connectionCts . Cancel ( ) ;
505+
506+ SetDisconnected ( _disconnectError ) ;
507+ }
477508}
0 commit comments