Skip to content

Commit 7662ce6

Browse files
Copilotstephentoubhalter73
authored
Make ClientTransportClosedException public and unify transport exception handling (#1467)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com> Co-authored-by: halter73 <54385+halter73@users.noreply.github.com> Co-authored-by: Stephen Toub <stoub@microsoft.com>
1 parent 498de08 commit 7662ce6

11 files changed

Lines changed: 253 additions & 38 deletions
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
using ModelContextProtocol.Protocol;
2+
using System.Threading.Channels;
3+
4+
namespace ModelContextProtocol.Client;
5+
6+
/// <summary>
7+
/// An <see cref="IOException"/> that indicates the client transport was closed, carrying
8+
/// structured <see cref="ClientCompletionDetails"/> about why the closure occurred.
9+
/// </summary>
10+
/// <remarks>
11+
/// <para>
12+
/// This exception is thrown when an MCP transport closes, either during initialization
13+
/// (e.g., from <see cref="McpClient.CreateAsync"/>) or during an active session.
14+
/// Callers can catch this exception to access the <see cref="Details"/> property
15+
/// for structured information about the closure.
16+
/// </para>
17+
/// <para>
18+
/// For stdio-based transports, the <see cref="Details"/> will be a
19+
/// <see cref="StdioClientCompletionDetails"/> instance providing access to the
20+
/// server process exit code, process ID, and standard error output.
21+
/// </para>
22+
/// <para>
23+
/// Custom <see cref="ITransport"/> implementations can provide their own
24+
/// <see cref="ClientCompletionDetails"/>-derived types by completing their
25+
/// <see cref="ChannelWriter{T}"/> with this exception.
26+
/// </para>
27+
/// </remarks>
28+
public sealed class ClientTransportClosedException(ClientCompletionDetails details) :
29+
IOException(details.Exception?.Message ?? "The transport was closed.", details.Exception)
30+
{
31+
/// <summary>
32+
/// Gets the structured details about why the transport was closed.
33+
/// </summary>
34+
/// <remarks>
35+
/// The concrete type of the returned <see cref="ClientCompletionDetails"/> depends on
36+
/// the transport that was used. For example, <see cref="StdioClientCompletionDetails"/>
37+
/// for stdio-based transports and <see cref="HttpClientCompletionDetails"/> for HTTP-based transports.
38+
/// </remarks>
39+
public ClientCompletionDetails Details { get; } = details;
40+
}

src/ModelContextProtocol.Core/Client/McpClient.Methods.cs

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
using Microsoft.Extensions.Logging;
22
using ModelContextProtocol.Protocol;
33
using ModelContextProtocol.Server;
4+
using System.Diagnostics;
45
using System.Diagnostics.CodeAnalysis;
56
using System.Text.Json;
67
using System.Text.Json.Nodes;
@@ -52,20 +53,49 @@ public static async Task<McpClient> CreateAsync(
5253
{
5354
await clientSession.ConnectAsync(cancellationToken).ConfigureAwait(false);
5455
}
55-
catch
56+
catch (Exception ex) when (ex is not OperationCanceledException and not ClientTransportClosedException)
5657
{
57-
try
58+
// ConnectAsync already disposed the session (which includes awaiting Completion).
59+
// Check if the transport provided structured completion details indicating
60+
// why the transport closed that aren't already in the original exception chain.
61+
Debug.Assert(clientSession.Completion.IsCompleted, "Completion should already be finished after ConnectAsync's DisposeAsync.");
62+
var completionDetails = await clientSession.Completion.ConfigureAwait(false);
63+
64+
// If the transport closed with a non-graceful error (e.g., server process exited)
65+
// and the completion details carry an exception that's NOT already in the original
66+
// exception chain, throw a ClientTransportClosedException with the structured details so
67+
// callers can programmatically inspect the closure reason (exit code, stderr, etc.).
68+
// When the same exception is already in the chain (e.g., HttpRequestException from
69+
// an HTTP transport), the original exception is more appropriate to re-throw.
70+
if (completionDetails.Exception is { } detailsException &&
71+
!ExceptionChainContains(ex, detailsException))
5872
{
59-
await clientSession.DisposeAsync().ConfigureAwait(false);
73+
throw new ClientTransportClosedException(completionDetails);
6074
}
61-
catch { } // allow the original exception to propagate
6275

6376
throw;
6477
}
6578

6679
return clientSession;
6780
}
6881

82+
/// <summary>
83+
/// Returns <see langword="true"/> if <paramref name="target"/> is the same object as
84+
/// <paramref name="exception"/> or any exception in its <see cref="Exception.InnerException"/> chain.
85+
/// </summary>
86+
private static bool ExceptionChainContains(Exception exception, Exception target)
87+
{
88+
for (Exception? current = exception; current is not null; current = current.InnerException)
89+
{
90+
if (ReferenceEquals(current, target))
91+
{
92+
return true;
93+
}
94+
}
95+
96+
return false;
97+
}
98+
6999
/// <summary>
70100
/// Recreates an <see cref="McpClient"/> using an existing transport session without sending a new initialize request.
71101
/// </summary>

src/ModelContextProtocol.Core/Client/SseClientSessionTransport.cs

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -58,11 +58,15 @@ public async Task ConnectAsync(CancellationToken cancellationToken = default)
5858

5959
await _connectionEstablished.Task.WaitAsync(_options.ConnectionTimeout, cancellationToken).ConfigureAwait(false);
6060
}
61+
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
62+
{
63+
throw;
64+
}
6165
catch (Exception ex)
6266
{
6367
LogTransportConnectFailed(Name, ex);
6468
await CloseAsync().ConfigureAwait(false);
65-
throw new InvalidOperationException("Failed to connect transport", ex);
69+
throw new IOException("Failed to connect transport.", ex);
6670
}
6771
}
6872

@@ -125,7 +129,7 @@ private async Task CloseAsync()
125129
}
126130
finally
127131
{
128-
SetDisconnected(new TransportClosedException(new HttpClientCompletionDetails()));
132+
SetDisconnected(new ClientTransportClosedException(new HttpClientCompletionDetails()));
129133
}
130134
}
131135

@@ -186,7 +190,7 @@ private async Task ReceiveMessagesAsync(CancellationToken cancellationToken)
186190
}
187191
else
188192
{
189-
SetDisconnected(new TransportClosedException(new HttpClientCompletionDetails
193+
SetDisconnected(new ClientTransportClosedException(new HttpClientCompletionDetails
190194
{
191195
HttpStatusCode = failureStatusCode,
192196
Exception = ex,
@@ -199,7 +203,7 @@ private async Task ReceiveMessagesAsync(CancellationToken cancellationToken)
199203
}
200204
finally
201205
{
202-
SetDisconnected(new TransportClosedException(new HttpClientCompletionDetails()));
206+
SetDisconnected(new ClientTransportClosedException(new HttpClientCompletionDetails()));
203207
}
204208
}
205209

src/ModelContextProtocol.Core/Client/StdioClientSessionTransport.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,12 +64,12 @@ protected override async ValueTask CleanupAsync(Exception? error = null, Cancell
6464
_process,
6565
processRunning: true,
6666
_options.ShutdownTimeout,
67-
beforeDispose: () => SetDisconnected(new TransportClosedException(BuildCompletionDetails(error))));
67+
beforeDispose: () => SetDisconnected(new ClientTransportClosedException(BuildCompletionDetails(error))));
6868
}
6969
catch (Exception ex)
7070
{
7171
LogTransportShutdownFailed(Name, ex);
72-
SetDisconnected(new TransportClosedException(BuildCompletionDetails(error)));
72+
SetDisconnected(new ClientTransportClosedException(BuildCompletionDetails(error)));
7373
}
7474

7575
// And handle cleanup in the base type. SetDisconnected has already been

src/ModelContextProtocol.Core/Client/StreamClientSessionTransport.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,10 @@ public override async Task SendMessageAsync(JsonRpcMessage message, Cancellation
8585
await _serverInputStream.WriteAsync(s_newlineBytes, cancellationToken).ConfigureAwait(false);
8686
await _serverInputStream.FlushAsync(cancellationToken).ConfigureAwait(false);
8787
}
88+
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
89+
{
90+
throw;
91+
}
8892
catch (Exception ex)
8993
{
9094
LogTransportSendFailed(Name, id, ex);

src/ModelContextProtocol.Core/Client/StreamableHttpClientSessionTransport.cs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ internal sealed partial class StreamableHttpClientSessionTransport : TransportBa
2525

2626
private string? _negotiatedProtocolVersion;
2727
private Task? _getReceiveTask;
28-
private volatile TransportClosedException? _disconnectError;
28+
private volatile ClientTransportClosedException? _disconnectError;
2929

3030
private readonly SemaphoreSlim _disposeLock = new(1, 1);
3131
private bool _disposed;
@@ -200,7 +200,7 @@ public override async ValueTask DisposeAsync()
200200
{
201201
// _disconnectError is set when the server returns 404 indicating session expiry.
202202
// When null, this is a graceful client-initiated closure (no error).
203-
SetDisconnected(_disconnectError ?? new TransportClosedException(new HttpClientCompletionDetails()));
203+
SetDisconnected(_disconnectError ?? new ClientTransportClosedException(new HttpClientCompletionDetails()));
204204
}
205205
}
206206
}
@@ -491,7 +491,7 @@ private void SetSessionExpired()
491491
{
492492
// Store the error before canceling so DisposeAsync can use it if it races us, especially
493493
// after the call to Cancel below, to invoke SetDisconnected.
494-
_disconnectError = new TransportClosedException(new HttpClientCompletionDetails
494+
_disconnectError = new ClientTransportClosedException(new HttpClientCompletionDetails
495495
{
496496
HttpStatusCode = HttpStatusCode.NotFound,
497497
Exception = new McpException(

src/ModelContextProtocol.Core/Client/TransportClosedException.cs

Lines changed: 0 additions & 19 deletions
This file was deleted.

src/ModelContextProtocol.Core/McpSessionHandler.cs

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -156,7 +156,7 @@ public McpSessionHandler(
156156
/// <summary>
157157
/// Gets a task that completes when the client session has completed, providing details about the closure.
158158
/// Completion details are resolved from the transport's channel completion exception: if a transport
159-
/// completes its channel with a <see cref="TransportClosedException"/>, the wrapped
159+
/// completes its channel with a <see cref="ClientTransportClosedException"/>, the wrapped
160160
/// <see cref="ClientCompletionDetails"/> is unwrapped. Otherwise, a default instance is returned.
161161
/// </summary>
162162
internal Task<ClientCompletionDetails> CompletionTask =>
@@ -325,16 +325,23 @@ ex is OperationCanceledException &&
325325
}
326326

327327
// Fail any pending requests, as they'll never be satisfied.
328+
// If the transport's channel was completed with a ClientTransportClosedException,
329+
// propagate it so callers can access the structured completion details.
330+
Exception pendingException =
331+
_transport.MessageReader.Completion is { IsCompleted: true, IsFaulted: true } completion &&
332+
completion.Exception?.InnerException is { } innerException
333+
? innerException
334+
: new IOException("The server shut down unexpectedly.");
328335
foreach (var entry in _pendingRequests)
329336
{
330-
entry.Value.TrySetException(new IOException("The server shut down unexpectedly."));
337+
entry.Value.TrySetException(pendingException);
331338
}
332339
}
333340
}
334341

335342
/// <summary>
336343
/// Resolves <see cref="ClientCompletionDetails"/> from the transport's channel completion.
337-
/// If the channel was completed with a <see cref="TransportClosedException"/>, the wrapped
344+
/// If the channel was completed with a <see cref="ClientTransportClosedException"/>, the wrapped
338345
/// details are returned. Otherwise a default instance is created from the completion state.
339346
/// </summary>
340347
private static async Task<ClientCompletionDetails> GetCompletionDetailsAsync(Task channelCompletion)
@@ -344,7 +351,7 @@ private static async Task<ClientCompletionDetails> GetCompletionDetailsAsync(Tas
344351
await channelCompletion.ConfigureAwait(false);
345352
return new ClientCompletionDetails();
346353
}
347-
catch (TransportClosedException tce)
354+
catch (ClientTransportClosedException tce)
348355
{
349356
return tce.Details;
350357
}
@@ -942,7 +949,7 @@ public async ValueTask DisposeAsync()
942949
catch
943950
{
944951
// Ignore exceptions from the message processing loop. It may fault with
945-
// OperationCanceledException on normal shutdown or TransportClosedException
952+
// OperationCanceledException on normal shutdown or ClientTransportClosedException
946953
// when the transport's channel completes with an error.
947954
}
948955
}

src/ModelContextProtocol.Core/Server/StreamServerTransport.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,10 @@ public override async Task SendMessageAsync(JsonRpcMessage message, Cancellation
8080
await _outputStream.WriteAsync(s_newlineBytes, cancellationToken).ConfigureAwait(false);
8181
await _outputStream.FlushAsync(cancellationToken).ConfigureAwait(false);
8282
}
83+
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
84+
{
85+
throw;
86+
}
8387
catch (Exception ex)
8488
{
8589
LogTransportSendFailed(Name, id, ex);

tests/ModelContextProtocol.Tests/Client/ClientCompletionDetailsTests.cs

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,48 @@ namespace ModelContextProtocol.Tests.Client;
44

55
public class ClientCompletionDetailsTests
66
{
7+
[Fact]
8+
public void ClientTransportClosedException_ExposesDetails()
9+
{
10+
var details = new StdioClientCompletionDetails
11+
{
12+
ExitCode = 42,
13+
ProcessId = 12345,
14+
StandardErrorTail = ["error line"],
15+
Exception = new IOException("process exited"),
16+
};
17+
18+
var exception = new ClientTransportClosedException(details);
19+
20+
Assert.IsType<StdioClientCompletionDetails>(exception.Details);
21+
var stdioDetails = (StdioClientCompletionDetails)exception.Details;
22+
Assert.Equal(42, stdioDetails.ExitCode);
23+
Assert.Equal(12345, stdioDetails.ProcessId);
24+
Assert.Equal(["error line"], stdioDetails.StandardErrorTail);
25+
Assert.Equal("process exited", exception.Message);
26+
Assert.IsType<IOException>(exception.InnerException);
27+
}
28+
29+
[Fact]
30+
public void ClientTransportClosedException_WithNullException_HasDefaultMessage()
31+
{
32+
var details = new ClientCompletionDetails();
33+
34+
var exception = new ClientTransportClosedException(details);
35+
36+
Assert.Equal("The transport was closed.", exception.Message);
37+
Assert.Null(exception.InnerException);
38+
Assert.Same(details, exception.Details);
39+
}
40+
41+
[Fact]
42+
public void ClientTransportClosedException_IsIOException()
43+
{
44+
var details = new ClientCompletionDetails();
45+
IOException exception = new ClientTransportClosedException(details);
46+
Assert.IsType<ClientTransportClosedException>(exception);
47+
}
48+
749
[Fact]
850
public void ClientCompletionDetails_PropertiesRoundtrip()
951
{

0 commit comments

Comments
 (0)