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); + } + } }