diff --git a/src/ModelContextProtocol.Core/Client/ClientTransportClosedException.cs b/src/ModelContextProtocol.Core/Client/ClientTransportClosedException.cs
new file mode 100644
index 000000000..611edf8a5
--- /dev/null
+++ b/src/ModelContextProtocol.Core/Client/ClientTransportClosedException.cs
@@ -0,0 +1,40 @@
+using ModelContextProtocol.Protocol;
+using System.Threading.Channels;
+
+namespace ModelContextProtocol.Client;
+
+///
+/// An that indicates the client transport was closed, carrying
+/// structured about why the closure occurred.
+///
+///
+///
+/// This exception is thrown when an MCP transport closes, either during initialization
+/// (e.g., from ) or during an active session.
+/// Callers can catch this exception to access the property
+/// for structured information about the closure.
+///
+///
+/// For stdio-based transports, the will be a
+/// instance providing access to the
+/// server process exit code, process ID, and standard error output.
+///
+///
+/// Custom implementations can provide their own
+/// -derived types by completing their
+/// with this exception.
+///
+///
+public sealed class ClientTransportClosedException(ClientCompletionDetails details) :
+ IOException(details.Exception?.Message ?? "The transport was closed.", details.Exception)
+{
+ ///
+ /// Gets the structured details about why the transport was closed.
+ ///
+ ///
+ /// The concrete type of the returned depends on
+ /// the transport that was used. For example,
+ /// for stdio-based transports and for HTTP-based transports.
+ ///
+ public ClientCompletionDetails Details { get; } = details;
+}
diff --git a/src/ModelContextProtocol.Core/Client/McpClient.Methods.cs b/src/ModelContextProtocol.Core/Client/McpClient.Methods.cs
index 6490967d0..673f66420 100644
--- a/src/ModelContextProtocol.Core/Client/McpClient.Methods.cs
+++ b/src/ModelContextProtocol.Core/Client/McpClient.Methods.cs
@@ -1,6 +1,7 @@
using Microsoft.Extensions.Logging;
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;
+using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
using System.Text.Json.Nodes;
@@ -52,13 +53,25 @@ public static async Task CreateAsync(
{
await clientSession.ConnectAsync(cancellationToken).ConfigureAwait(false);
}
- catch
+ catch (Exception ex) when (ex is not OperationCanceledException and not ClientTransportClosedException)
{
- try
+ // ConnectAsync already disposed the session (which includes awaiting Completion).
+ // Check if the transport provided structured completion details indicating
+ // why the transport closed that aren't already in the original exception chain.
+ Debug.Assert(clientSession.Completion.IsCompleted, "Completion should already be finished after ConnectAsync's DisposeAsync.");
+ var completionDetails = await clientSession.Completion.ConfigureAwait(false);
+
+ // If the transport closed with a non-graceful error (e.g., server process exited)
+ // and the completion details carry an exception that's NOT already in the original
+ // exception chain, throw a ClientTransportClosedException with the structured details so
+ // callers can programmatically inspect the closure reason (exit code, stderr, etc.).
+ // When the same exception is already in the chain (e.g., HttpRequestException from
+ // an HTTP transport), the original exception is more appropriate to re-throw.
+ if (completionDetails.Exception is { } detailsException &&
+ !ExceptionChainContains(ex, detailsException))
{
- await clientSession.DisposeAsync().ConfigureAwait(false);
+ throw new ClientTransportClosedException(completionDetails);
}
- catch { } // allow the original exception to propagate
throw;
}
@@ -66,6 +79,23 @@ public static async Task CreateAsync(
return clientSession;
}
+ ///
+ /// Returns if is the same object as
+ /// or any exception in its chain.
+ ///
+ private static bool ExceptionChainContains(Exception exception, Exception target)
+ {
+ for (Exception? current = exception; current is not null; current = current.InnerException)
+ {
+ if (ReferenceEquals(current, target))
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
///
/// Recreates an using an existing transport session without sending a new initialize request.
///
diff --git a/src/ModelContextProtocol.Core/Client/SseClientSessionTransport.cs b/src/ModelContextProtocol.Core/Client/SseClientSessionTransport.cs
index 0bcc69417..fb918989b 100644
--- a/src/ModelContextProtocol.Core/Client/SseClientSessionTransport.cs
+++ b/src/ModelContextProtocol.Core/Client/SseClientSessionTransport.cs
@@ -58,11 +58,15 @@ public async Task ConnectAsync(CancellationToken cancellationToken = default)
await _connectionEstablished.Task.WaitAsync(_options.ConnectionTimeout, cancellationToken).ConfigureAwait(false);
}
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
+ {
+ throw;
+ }
catch (Exception ex)
{
LogTransportConnectFailed(Name, ex);
await CloseAsync().ConfigureAwait(false);
- throw new InvalidOperationException("Failed to connect transport", ex);
+ throw new IOException("Failed to connect transport.", ex);
}
}
@@ -125,7 +129,7 @@ private async Task CloseAsync()
}
finally
{
- SetDisconnected(new TransportClosedException(new HttpClientCompletionDetails()));
+ SetDisconnected(new ClientTransportClosedException(new HttpClientCompletionDetails()));
}
}
@@ -186,7 +190,7 @@ private async Task ReceiveMessagesAsync(CancellationToken cancellationToken)
}
else
{
- SetDisconnected(new TransportClosedException(new HttpClientCompletionDetails
+ SetDisconnected(new ClientTransportClosedException(new HttpClientCompletionDetails
{
HttpStatusCode = failureStatusCode,
Exception = ex,
@@ -199,7 +203,7 @@ private async Task ReceiveMessagesAsync(CancellationToken cancellationToken)
}
finally
{
- SetDisconnected(new TransportClosedException(new HttpClientCompletionDetails()));
+ SetDisconnected(new ClientTransportClosedException(new HttpClientCompletionDetails()));
}
}
diff --git a/src/ModelContextProtocol.Core/Client/StdioClientSessionTransport.cs b/src/ModelContextProtocol.Core/Client/StdioClientSessionTransport.cs
index a92093246..48e743275 100644
--- a/src/ModelContextProtocol.Core/Client/StdioClientSessionTransport.cs
+++ b/src/ModelContextProtocol.Core/Client/StdioClientSessionTransport.cs
@@ -64,12 +64,12 @@ protected override async ValueTask CleanupAsync(Exception? error = null, Cancell
_process,
processRunning: true,
_options.ShutdownTimeout,
- beforeDispose: () => SetDisconnected(new TransportClosedException(BuildCompletionDetails(error))));
+ beforeDispose: () => SetDisconnected(new ClientTransportClosedException(BuildCompletionDetails(error))));
}
catch (Exception ex)
{
LogTransportShutdownFailed(Name, ex);
- SetDisconnected(new TransportClosedException(BuildCompletionDetails(error)));
+ SetDisconnected(new ClientTransportClosedException(BuildCompletionDetails(error)));
}
// And handle cleanup in the base type. SetDisconnected has already been
diff --git a/src/ModelContextProtocol.Core/Client/StreamClientSessionTransport.cs b/src/ModelContextProtocol.Core/Client/StreamClientSessionTransport.cs
index 19306349f..34a19d5b9 100644
--- a/src/ModelContextProtocol.Core/Client/StreamClientSessionTransport.cs
+++ b/src/ModelContextProtocol.Core/Client/StreamClientSessionTransport.cs
@@ -85,6 +85,10 @@ public override async Task SendMessageAsync(JsonRpcMessage message, Cancellation
await _serverInputStream.WriteAsync(s_newlineBytes, cancellationToken).ConfigureAwait(false);
await _serverInputStream.FlushAsync(cancellationToken).ConfigureAwait(false);
}
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
+ {
+ throw;
+ }
catch (Exception ex)
{
LogTransportSendFailed(Name, id, ex);
diff --git a/src/ModelContextProtocol.Core/Client/StreamableHttpClientSessionTransport.cs b/src/ModelContextProtocol.Core/Client/StreamableHttpClientSessionTransport.cs
index 9d82f9310..f51e236b4 100644
--- a/src/ModelContextProtocol.Core/Client/StreamableHttpClientSessionTransport.cs
+++ b/src/ModelContextProtocol.Core/Client/StreamableHttpClientSessionTransport.cs
@@ -25,7 +25,7 @@ internal sealed partial class StreamableHttpClientSessionTransport : TransportBa
private string? _negotiatedProtocolVersion;
private Task? _getReceiveTask;
- private volatile TransportClosedException? _disconnectError;
+ private volatile ClientTransportClosedException? _disconnectError;
private readonly SemaphoreSlim _disposeLock = new(1, 1);
private bool _disposed;
@@ -200,7 +200,7 @@ public override async ValueTask DisposeAsync()
{
// _disconnectError is set when the server returns 404 indicating session expiry.
// When null, this is a graceful client-initiated closure (no error).
- SetDisconnected(_disconnectError ?? new TransportClosedException(new HttpClientCompletionDetails()));
+ SetDisconnected(_disconnectError ?? new ClientTransportClosedException(new HttpClientCompletionDetails()));
}
}
}
@@ -491,7 +491,7 @@ private void SetSessionExpired()
{
// Store the error before canceling so DisposeAsync can use it if it races us, especially
// after the call to Cancel below, to invoke SetDisconnected.
- _disconnectError = new TransportClosedException(new HttpClientCompletionDetails
+ _disconnectError = new ClientTransportClosedException(new HttpClientCompletionDetails
{
HttpStatusCode = HttpStatusCode.NotFound,
Exception = new McpException(
diff --git a/src/ModelContextProtocol.Core/Client/TransportClosedException.cs b/src/ModelContextProtocol.Core/Client/TransportClosedException.cs
deleted file mode 100644
index 55d711991..000000000
--- a/src/ModelContextProtocol.Core/Client/TransportClosedException.cs
+++ /dev/null
@@ -1,19 +0,0 @@
-using ModelContextProtocol.Protocol;
-using System.Threading.Channels;
-
-namespace ModelContextProtocol.Client;
-
-///
-/// used to smuggle through
-/// the mechanism.
-///
-///
-/// This could be made public in the future to allow custom
-/// implementations to provide their own -derived types
-/// by completing their channel with this exception.
-///
-internal sealed class TransportClosedException(ClientCompletionDetails details) :
- IOException(details.Exception?.Message, details.Exception)
-{
- public ClientCompletionDetails Details { get; } = details;
-}
diff --git a/src/ModelContextProtocol.Core/McpSessionHandler.cs b/src/ModelContextProtocol.Core/McpSessionHandler.cs
index 048ae046f..24543fd3e 100644
--- a/src/ModelContextProtocol.Core/McpSessionHandler.cs
+++ b/src/ModelContextProtocol.Core/McpSessionHandler.cs
@@ -156,7 +156,7 @@ public McpSessionHandler(
///
/// Gets a task that completes when the client session has completed, providing details about the closure.
/// Completion details are resolved from the transport's channel completion exception: if a transport
- /// completes its channel with a , the wrapped
+ /// completes its channel with a , the wrapped
/// is unwrapped. Otherwise, a default instance is returned.
///
internal Task CompletionTask =>
@@ -325,16 +325,23 @@ ex is OperationCanceledException &&
}
// Fail any pending requests, as they'll never be satisfied.
+ // If the transport's channel was completed with a ClientTransportClosedException,
+ // propagate it so callers can access the structured completion details.
+ Exception pendingException =
+ _transport.MessageReader.Completion is { IsCompleted: true, IsFaulted: true } completion &&
+ completion.Exception?.InnerException is { } innerException
+ ? innerException
+ : new IOException("The server shut down unexpectedly.");
foreach (var entry in _pendingRequests)
{
- entry.Value.TrySetException(new IOException("The server shut down unexpectedly."));
+ entry.Value.TrySetException(pendingException);
}
}
}
///
/// Resolves from the transport's channel completion.
- /// If the channel was completed with a , the wrapped
+ /// If the channel was completed with a , the wrapped
/// details are returned. Otherwise a default instance is created from the completion state.
///
private static async Task GetCompletionDetailsAsync(Task channelCompletion)
@@ -344,7 +351,7 @@ private static async Task GetCompletionDetailsAsync(Tas
await channelCompletion.ConfigureAwait(false);
return new ClientCompletionDetails();
}
- catch (TransportClosedException tce)
+ catch (ClientTransportClosedException tce)
{
return tce.Details;
}
@@ -942,7 +949,7 @@ public async ValueTask DisposeAsync()
catch
{
// Ignore exceptions from the message processing loop. It may fault with
- // OperationCanceledException on normal shutdown or TransportClosedException
+ // OperationCanceledException on normal shutdown or ClientTransportClosedException
// when the transport's channel completes with an error.
}
}
diff --git a/src/ModelContextProtocol.Core/Server/StreamServerTransport.cs b/src/ModelContextProtocol.Core/Server/StreamServerTransport.cs
index 2202337f1..5e1a106c5 100644
--- a/src/ModelContextProtocol.Core/Server/StreamServerTransport.cs
+++ b/src/ModelContextProtocol.Core/Server/StreamServerTransport.cs
@@ -80,6 +80,10 @@ public override async Task SendMessageAsync(JsonRpcMessage message, Cancellation
await _outputStream.WriteAsync(s_newlineBytes, cancellationToken).ConfigureAwait(false);
await _outputStream.FlushAsync(cancellationToken).ConfigureAwait(false);
}
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
+ {
+ throw;
+ }
catch (Exception ex)
{
LogTransportSendFailed(Name, id, ex);
diff --git a/tests/ModelContextProtocol.Tests/Client/ClientCompletionDetailsTests.cs b/tests/ModelContextProtocol.Tests/Client/ClientCompletionDetailsTests.cs
index b4d50850b..64e240657 100644
--- a/tests/ModelContextProtocol.Tests/Client/ClientCompletionDetailsTests.cs
+++ b/tests/ModelContextProtocol.Tests/Client/ClientCompletionDetailsTests.cs
@@ -4,6 +4,48 @@ namespace ModelContextProtocol.Tests.Client;
public class ClientCompletionDetailsTests
{
+ [Fact]
+ public void ClientTransportClosedException_ExposesDetails()
+ {
+ var details = new StdioClientCompletionDetails
+ {
+ ExitCode = 42,
+ ProcessId = 12345,
+ StandardErrorTail = ["error line"],
+ Exception = new IOException("process exited"),
+ };
+
+ var exception = new ClientTransportClosedException(details);
+
+ Assert.IsType(exception.Details);
+ var stdioDetails = (StdioClientCompletionDetails)exception.Details;
+ Assert.Equal(42, stdioDetails.ExitCode);
+ Assert.Equal(12345, stdioDetails.ProcessId);
+ Assert.Equal(["error line"], stdioDetails.StandardErrorTail);
+ Assert.Equal("process exited", exception.Message);
+ Assert.IsType(exception.InnerException);
+ }
+
+ [Fact]
+ public void ClientTransportClosedException_WithNullException_HasDefaultMessage()
+ {
+ var details = new ClientCompletionDetails();
+
+ var exception = new ClientTransportClosedException(details);
+
+ Assert.Equal("The transport was closed.", exception.Message);
+ Assert.Null(exception.InnerException);
+ Assert.Same(details, exception.Details);
+ }
+
+ [Fact]
+ public void ClientTransportClosedException_IsIOException()
+ {
+ var details = new ClientCompletionDetails();
+ IOException exception = new ClientTransportClosedException(details);
+ Assert.IsType(exception);
+ }
+
[Fact]
public void ClientCompletionDetails_PropertiesRoundtrip()
{
diff --git a/tests/ModelContextProtocol.Tests/Client/McpClientCreationTests.cs b/tests/ModelContextProtocol.Tests/Client/McpClientCreationTests.cs
index 8fd67f7ac..b2935d247 100644
--- a/tests/ModelContextProtocol.Tests/Client/McpClientCreationTests.cs
+++ b/tests/ModelContextProtocol.Tests/Client/McpClientCreationTests.cs
@@ -1,12 +1,14 @@
+using Microsoft.Extensions.Logging;
using ModelContextProtocol.Client;
using ModelContextProtocol.Protocol;
+using ModelContextProtocol.Tests.Utils;
using System.IO.Pipelines;
using System.Text.Json;
using System.Threading.Channels;
namespace ModelContextProtocol.Tests.Client;
-public class McpClientCreationTests
+public class McpClientCreationTests(ITestOutputHelper testOutputHelper) : LoggedTest(testOutputHelper)
{
[Fact]
public async Task CreateAsync_WithInvalidArgs_Throws()
@@ -101,6 +103,51 @@ public async Task CreateAsync_WithCapabilitiesOptions(Type transportType)
}
}
+ [Fact]
+ public async Task CreateAsync_TransportChannelClosed_ThrowsClientTransportClosedException()
+ {
+ // Arrange - transport completes its read channel with ClientTransportClosedException
+ // when the client tries to send the initialize request (simulating a server process
+ // exit detected by the reader loop). SendMessageAsync returns successfully —
+ // only the read side fails.
+ var transport = new ChannelClosedDuringInitTransport();
+
+ // Act & Assert
+ var ex = await Assert.ThrowsAsync(
+ () => McpClient.CreateAsync(transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken));
+
+ var details = Assert.IsType(ex.Details);
+ Assert.Equal(42, details.ExitCode);
+ Assert.Equal(9999, details.ProcessId);
+ Assert.NotNull(details.StandardErrorTail);
+ Assert.Equal("Feature disabled", details.StandardErrorTail![0]);
+
+ // Verify initialization error was logged
+ Assert.Contains(MockLoggerProvider.LogMessages, log =>
+ log.LogLevel == LogLevel.Error &&
+ log.Message.Contains("client initialization error"));
+ }
+
+ [Fact]
+ public async Task CreateAsync_SendFails_PropagatesOriginalIOException()
+ {
+ // Arrange - transport throws IOException from SendMessageAsync, but the channel
+ // is not completed with ClientTransportClosedException. The original IOException should
+ // propagate without being wrapped in ClientTransportClosedException.
+ var transport = new SendFailsDuringInitTransport();
+
+ // Act & Assert
+ var ex = await Assert.ThrowsAsync(
+ () => McpClient.CreateAsync(transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken));
+
+ Assert.Equal(SendFailsDuringInitTransport.ExpectedMessage, ex.Message);
+
+ // Verify initialization error was logged
+ Assert.Contains(MockLoggerProvider.LogMessages, log =>
+ log.LogLevel == LogLevel.Error &&
+ log.Message.Contains("client initialization error"));
+ }
+
private class NopTransport : ITransport, IClientTransport
{
private readonly Channel _channel = Channel.CreateUnbounded();
@@ -155,4 +202,60 @@ public override Task SendMessageAsync(JsonRpcMessage message, CancellationToken
throw new InvalidOperationException(ExpectedMessage);
}
}
+
+ ///
+ /// Simulates a transport where the read channel closes with structured completion details during
+ /// initialization, as happens when a stdio server process exits before completing the handshake.
+ /// The send succeeds — only the read side carries the failure.
+ ///
+ private sealed class ChannelClosedDuringInitTransport : ITransport, IClientTransport
+ {
+ private readonly Channel _channel = Channel.CreateUnbounded();
+
+ public bool IsConnected => true;
+ public string? SessionId => null;
+
+ public ChannelReader MessageReader => _channel.Reader;
+
+ public Task ConnectAsync(CancellationToken cancellationToken = default) => Task.FromResult(this);
+
+ public ValueTask DisposeAsync()
+ {
+ _channel.Writer.TryComplete();
+ return default;
+ }
+
+ public string Name => "Test ChannelClosed Transport";
+
+ public Task SendMessageAsync(JsonRpcMessage message, CancellationToken cancellationToken = default)
+ {
+ // Simulate the server process exiting: complete the channel with a
+ // ClientTransportClosedException carrying structured completion details.
+ // The send itself succeeds — the failure comes from the read side.
+ var details = new StdioClientCompletionDetails
+ {
+ ExitCode = 42,
+ ProcessId = 9999,
+ StandardErrorTail = ["Feature disabled"],
+ Exception = new IOException("MCP server process exited unexpectedly (exit code: 42)"),
+ };
+
+ _channel.Writer.TryComplete(new ClientTransportClosedException(details));
+ return Task.CompletedTask;
+ }
+ }
+
+ ///
+ /// Simulates a transport where SendMessageAsync throws IOException but the channel
+ /// doesn't carry a ClientTransportClosedException (e.g., a write pipe break without structured details).
+ ///
+ private sealed class SendFailsDuringInitTransport : NopTransport
+ {
+ public const string ExpectedMessage = "Failed to write to transport";
+
+ public override Task SendMessageAsync(JsonRpcMessage message, CancellationToken cancellationToken = default)
+ {
+ throw new IOException(ExpectedMessage);
+ }
+ }
}