From 40c6dc80ed37f39848004a9c6e8efe5848c8fb31 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 25 Mar 2026 22:47:23 +0000 Subject: [PATCH 1/9] Initial plan From d3f9b5400df1a90335d8152ecffcba81a65ab097 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 25 Mar 2026 23:14:45 +0000 Subject: [PATCH 2/9] Make TransportClosedException public and propagate completion details through CreateAsync - Make TransportClosedException public with proper XML documentation - Update ProcessMessagesCoreAsync to propagate channel completion exception to pending requests instead of a generic IOException - Update McpClient.CreateAsync to throw TransportClosedException with structured completion details when the transport closes during initialization, while preserving existing behavior for cases where the original exception already carries the relevant information - Add unit tests for TransportClosedException construction and details access - Add integration test verifying TransportClosedException with StdioClientCompletionDetails when CreateAsync fails Agent-Logs-Url: https://github.com/modelcontextprotocol/csharp-sdk/sessions/dfc5fff0-2e51-4b55-a663-e9cd3477cad4 Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com> --- .../Client/McpClient.Methods.cs | 49 +++++++++++++++- .../Client/TransportClosedException.cs | 35 ++++++++--- .../McpSessionHandler.cs | 8 ++- .../Client/ClientCompletionDetailsTests.cs | 42 ++++++++++++++ .../Client/McpClientCreationTests.cs | 58 +++++++++++++++++++ 5 files changed, 183 insertions(+), 9 deletions(-) diff --git a/src/ModelContextProtocol.Core/Client/McpClient.Methods.cs b/src/ModelContextProtocol.Core/Client/McpClient.Methods.cs index 057831a4e..67b6b5b26 100644 --- a/src/ModelContextProtocol.Core/Client/McpClient.Methods.cs +++ b/src/ModelContextProtocol.Core/Client/McpClient.Methods.cs @@ -52,13 +52,43 @@ public static async Task CreateAsync( { await clientSession.ConnectAsync(cancellationToken).ConfigureAwait(false); } + catch (Exception ex) when (ex is not TransportClosedException) + { + // ConnectAsync already disposed the session. Call DisposeAsync again (no-op) + // to ensure cleanup, then check if the transport provided structured completion + // details indicating why the transport closed. + ClientCompletionDetails? completionDetails = null; + try + { + await clientSession.DisposeAsync().ConfigureAwait(false); + completionDetails = await clientSession.Completion.ConfigureAwait(false); + } + catch { } // allow the original exception to propagate if completion is unavailable + + // 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 TransportClosedException 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)) + { + throw new TransportClosedException(completionDetails); + } + + throw; + } catch { + // The exception is already a TransportClosedException (e.g., from + // ProcessMessagesCoreAsync propagating channel completion details). + // Just ensure cleanup and re-throw. try { await clientSession.DisposeAsync().ConfigureAwait(false); } - catch { } // allow the original exception to propagate + catch { } throw; } @@ -66,6 +96,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/TransportClosedException.cs b/src/ModelContextProtocol.Core/Client/TransportClosedException.cs index 55d711991..2e5248491 100644 --- a/src/ModelContextProtocol.Core/Client/TransportClosedException.cs +++ b/src/ModelContextProtocol.Core/Client/TransportClosedException.cs @@ -4,16 +4,37 @@ namespace ModelContextProtocol.Client; /// -/// used to smuggle through -/// the mechanism. +/// An that indicates the transport was closed, carrying +/// structured about why the closure occurred. /// /// -/// 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. +/// +/// 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. +/// /// -internal sealed class TransportClosedException(ClientCompletionDetails details) : - IOException(details.Exception?.Message, details.Exception) +public sealed class TransportClosedException(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/McpSessionHandler.cs b/src/ModelContextProtocol.Core/McpSessionHandler.cs index 6c4757399..29f762afe 100644 --- a/src/ModelContextProtocol.Core/McpSessionHandler.cs +++ b/src/ModelContextProtocol.Core/McpSessionHandler.cs @@ -325,9 +325,15 @@ ex is OperationCanceledException && } // Fail any pending requests, as they'll never be satisfied. + // If the transport's channel was completed with a TransportClosedException, + // propagate it so callers can access the structured completion details. + Exception pendingException = + _transport.MessageReader.Completion is { IsCompleted: true, IsFaulted: true } completion + ? completion.Exception!.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); } } } diff --git a/tests/ModelContextProtocol.Tests/Client/ClientCompletionDetailsTests.cs b/tests/ModelContextProtocol.Tests/Client/ClientCompletionDetailsTests.cs index b4d50850b..36eae4365 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 TransportClosedException_ExposesDetails() + { + var details = new StdioClientCompletionDetails + { + ExitCode = 42, + ProcessId = 12345, + StandardErrorTail = ["error line"], + Exception = new IOException("process exited"), + }; + + var exception = new TransportClosedException(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 TransportClosedException_WithNullException_HasDefaultMessage() + { + var details = new ClientCompletionDetails(); + + var exception = new TransportClosedException(details); + + Assert.Equal("The transport was closed.", exception.Message); + Assert.Null(exception.InnerException); + Assert.Same(details, exception.Details); + } + + [Fact] + public void TransportClosedException_IsIOException() + { + var details = new ClientCompletionDetails(); + IOException exception = new TransportClosedException(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..66e5ce4b5 100644 --- a/tests/ModelContextProtocol.Tests/Client/McpClientCreationTests.cs +++ b/tests/ModelContextProtocol.Tests/Client/McpClientCreationTests.cs @@ -101,6 +101,24 @@ public async Task CreateAsync_WithCapabilitiesOptions(Type transportType) } } + [Fact] + public async Task CreateAsync_TransportClosedDuringInit_ThrowsTransportClosedException() + { + // Arrange - a transport that completes its channel with a TransportClosedException + // when the client tries to send the initialize request (simulating a server process exit). + var transport = new TransportClosedDuringInitTransport(); + + // Act & Assert + var ex = await Assert.ThrowsAsync( + () => McpClient.CreateAsync(transport, 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]); + } + private class NopTransport : ITransport, IClientTransport { private readonly Channel _channel = Channel.CreateUnbounded(); @@ -155,4 +173,44 @@ public override Task SendMessageAsync(JsonRpcMessage message, CancellationToken throw new InvalidOperationException(ExpectedMessage); } } + + /// + /// Simulates a transport that closes with structured completion details during initialization, + /// as would happen when a stdio server process exits before completing the handshake. + /// + private sealed class TransportClosedDuringInitTransport : 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 TransportClosed Transport"; + + public Task SendMessageAsync(JsonRpcMessage message, CancellationToken cancellationToken = default) + { + // Simulate the server process exiting: complete the channel with a TransportClosedException + // carrying structured completion details, then throw IOException like the real transport does. + 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 TransportClosedException(details)); + throw new IOException("Failed to send message.", new IOException("Broken pipe")); + } + } } From aa218be8020fc0661dd85928ec3334d07ddc81df Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 25 Mar 2026 23:20:03 +0000 Subject: [PATCH 3/9] Address code review: fix null-safety on AggregateException.InnerException Agent-Logs-Url: https://github.com/modelcontextprotocol/csharp-sdk/sessions/dfc5fff0-2e51-4b55-a663-e9cd3477cad4 Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com> --- src/ModelContextProtocol.Core/McpSessionHandler.cs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/ModelContextProtocol.Core/McpSessionHandler.cs b/src/ModelContextProtocol.Core/McpSessionHandler.cs index 29f762afe..22ab09b0f 100644 --- a/src/ModelContextProtocol.Core/McpSessionHandler.cs +++ b/src/ModelContextProtocol.Core/McpSessionHandler.cs @@ -328,8 +328,9 @@ ex is OperationCanceledException && // If the transport's channel was completed with a TransportClosedException, // propagate it so callers can access the structured completion details. Exception pendingException = - _transport.MessageReader.Completion is { IsCompleted: true, IsFaulted: true } completion - ? completion.Exception!.InnerException! + _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) { From fde5d08b9f0ea2a9754375a676f14fe351616cbe Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 26 Mar 2026 02:38:03 +0000 Subject: [PATCH 4/9] Remove redundant DisposeAsync call and simplify catch blocks in CreateAsync ConnectAsync already calls DisposeAsync (which awaits Completion) before re-throwing, so the session is already disposed and Completion is resolved by the time CreateAsync's catch block runs. Agent-Logs-Url: https://github.com/modelcontextprotocol/csharp-sdk/sessions/dc4efc81-4b40-45c3-a743-59ade1c1c6df Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com> --- .../Client/McpClient.Methods.cs | 22 ++++--------------- 1 file changed, 4 insertions(+), 18 deletions(-) diff --git a/src/ModelContextProtocol.Core/Client/McpClient.Methods.cs b/src/ModelContextProtocol.Core/Client/McpClient.Methods.cs index 67b6b5b26..4e20c7022 100644 --- a/src/ModelContextProtocol.Core/Client/McpClient.Methods.cs +++ b/src/ModelContextProtocol.Core/Client/McpClient.Methods.cs @@ -52,15 +52,14 @@ public static async Task CreateAsync( { await clientSession.ConnectAsync(cancellationToken).ConfigureAwait(false); } - catch (Exception ex) when (ex is not TransportClosedException) + catch (Exception ex) when (ex is not OperationCanceledException and not TransportClosedException) { - // ConnectAsync already disposed the session. Call DisposeAsync again (no-op) - // to ensure cleanup, then check if the transport provided structured completion - // details indicating why the transport closed. + // 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. ClientCompletionDetails? completionDetails = null; try { - await clientSession.DisposeAsync().ConfigureAwait(false); completionDetails = await clientSession.Completion.ConfigureAwait(false); } catch { } // allow the original exception to propagate if completion is unavailable @@ -79,19 +78,6 @@ public static async Task CreateAsync( throw; } - catch - { - // The exception is already a TransportClosedException (e.g., from - // ProcessMessagesCoreAsync propagating channel completion details). - // Just ensure cleanup and re-throw. - try - { - await clientSession.DisposeAsync().ConfigureAwait(false); - } - catch { } - - throw; - } return clientSession; } From a43d0d87f36f1fe4e2cdc27d5751eb87ad6e16f8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 26 Mar 2026 15:27:18 +0000 Subject: [PATCH 5/9] Remove unnecessary try/catch around Completion await GetCompletionDetailsAsync already catches all exceptions internally and always returns a ClientCompletionDetails, so Completion never faults. Agent-Logs-Url: https://github.com/modelcontextprotocol/csharp-sdk/sessions/e1a0f673-063d-4707-916d-51824e00b557 Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com> --- .../Client/McpClient.Methods.cs | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/src/ModelContextProtocol.Core/Client/McpClient.Methods.cs b/src/ModelContextProtocol.Core/Client/McpClient.Methods.cs index 4e20c7022..e8ca3ae75 100644 --- a/src/ModelContextProtocol.Core/Client/McpClient.Methods.cs +++ b/src/ModelContextProtocol.Core/Client/McpClient.Methods.cs @@ -57,12 +57,7 @@ public static async Task CreateAsync( // 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. - ClientCompletionDetails? completionDetails = null; - try - { - completionDetails = await clientSession.Completion.ConfigureAwait(false); - } - catch { } // allow the original exception to propagate if completion is unavailable + 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 @@ -70,7 +65,7 @@ public static async Task CreateAsync( // 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 && + if (completionDetails.Exception is { } detailsException && !ExceptionChainContains(ex, detailsException)) { throw new TransportClosedException(completionDetails); From 8308c3b2b0cb7c28b8c78a9b6de3983e337e1ea8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 26 Mar 2026 15:32:41 +0000 Subject: [PATCH 6/9] Add Debug.Assert that Completion is already completed before awaiting it ConnectAsync's DisposeAsync already awaits Completion, so by the time CreateAsync's catch block runs, it's guaranteed to be completed. The assert documents this invariant. Agent-Logs-Url: https://github.com/modelcontextprotocol/csharp-sdk/sessions/69e63301-5b59-4d6e-bf84-d1e02e421c67 Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com> --- src/ModelContextProtocol.Core/Client/McpClient.Methods.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/ModelContextProtocol.Core/Client/McpClient.Methods.cs b/src/ModelContextProtocol.Core/Client/McpClient.Methods.cs index e8ca3ae75..498098d9b 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; @@ -57,6 +58,7 @@ public static async Task CreateAsync( // 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) From 4983cfb08188136c49c8123f4e2a05df478d113d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 26 Mar 2026 21:46:45 +0000 Subject: [PATCH 7/9] Separate read vs write failure tests and verify error logs Split the TransportClosedDuringInit test into two deterministic tests: - ChannelClosed test: SendMessageAsync returns successfully, only the read channel completes with TransportClosedException. Verifies the structured completion details and error log. - SendFails test: SendMessageAsync throws IOException without TransportClosedException in the channel. Verifies the original IOException propagates and error log is produced. Make test class inherit from LoggedTest to verify initialization error logs via MockLoggerProvider. Agent-Logs-Url: https://github.com/modelcontextprotocol/csharp-sdk/sessions/97b170d4-86a2-4f08-998c-65b1c13dbfdb Co-authored-by: halter73 <54385+halter73@users.noreply.github.com> --- .../Client/McpClientCreationTests.cs | 71 +++++++++++++++---- 1 file changed, 58 insertions(+), 13 deletions(-) diff --git a/tests/ModelContextProtocol.Tests/Client/McpClientCreationTests.cs b/tests/ModelContextProtocol.Tests/Client/McpClientCreationTests.cs index 66e5ce4b5..19c8a2d76 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() @@ -102,21 +104,48 @@ public async Task CreateAsync_WithCapabilitiesOptions(Type transportType) } [Fact] - public async Task CreateAsync_TransportClosedDuringInit_ThrowsTransportClosedException() + public async Task CreateAsync_TransportChannelClosed_ThrowsTransportClosedException() { - // Arrange - a transport that completes its channel with a TransportClosedException - // when the client tries to send the initialize request (simulating a server process exit). - var transport = new TransportClosedDuringInitTransport(); + // Arrange - transport completes its read channel with TransportClosedException + // 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, cancellationToken: TestContext.Current.CancellationToken)); + () => 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 TransportClosedException. The original IOException should + // propagate without being wrapped in TransportClosedException. + 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 @@ -175,10 +204,11 @@ public override Task SendMessageAsync(JsonRpcMessage message, CancellationToken } /// - /// Simulates a transport that closes with structured completion details during initialization, - /// as would happen when a stdio server process exits before completing the handshake. + /// 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 TransportClosedDuringInitTransport : ITransport, IClientTransport + private sealed class ChannelClosedDuringInitTransport : ITransport, IClientTransport { private readonly Channel _channel = Channel.CreateUnbounded(); @@ -195,12 +225,13 @@ public ValueTask DisposeAsync() return default; } - public string Name => "Test TransportClosed Transport"; + public string Name => "Test ChannelClosed Transport"; public Task SendMessageAsync(JsonRpcMessage message, CancellationToken cancellationToken = default) { - // Simulate the server process exiting: complete the channel with a TransportClosedException - // carrying structured completion details, then throw IOException like the real transport does. + // Simulate the server process exiting: complete the channel with a + // TransportClosedException carrying structured completion details. + // The send itself succeeds — the failure comes from the read side. var details = new StdioClientCompletionDetails { ExitCode = 42, @@ -210,7 +241,21 @@ public Task SendMessageAsync(JsonRpcMessage message, CancellationToken cancellat }; _channel.Writer.TryComplete(new TransportClosedException(details)); - throw new IOException("Failed to send message.", new IOException("Broken pipe")); + return Task.CompletedTask; + } + } + + /// + /// Simulates a transport where SendMessageAsync throws IOException but the channel + /// doesn't carry a TransportClosedException (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); } } } From 2a5e1d74a9bcd06f131bfc1f00bd83cbe6c301db Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 26 Mar 2026 21:55:18 +0000 Subject: [PATCH 8/9] Propagate caller-triggered cancellation in transport exception handling Cherry-pick from halter73/transport-exceptions (fd2d71e, 19a1f2c): - StreamClientSessionTransport.SendMessageAsync: re-throw OperationCanceledException when the caller's token triggers it, instead of wrapping in IOException. - StreamServerTransport.SendMessageAsync: same pattern. - SseClientSessionTransport.ConnectAsync: add OperationCanceledException guard and change InvalidOperationException to IOException for consistency (connection failure is I/O, not invalid state). Co-authored-by: Stephen Halter Agent-Logs-Url: https://github.com/modelcontextprotocol/csharp-sdk/sessions/f1ba1333-443c-4239-8205-c32db49e9f22 Co-authored-by: halter73 <54385+halter73@users.noreply.github.com> --- .../Client/SseClientSessionTransport.cs | 6 +++++- .../Client/StreamClientSessionTransport.cs | 4 ++++ .../Server/StreamServerTransport.cs | 4 ++++ 3 files changed, 13 insertions(+), 1 deletion(-) diff --git a/src/ModelContextProtocol.Core/Client/SseClientSessionTransport.cs b/src/ModelContextProtocol.Core/Client/SseClientSessionTransport.cs index 0bcc69417..65690fa36 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); } } 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/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); From 21319af4e32ea285a85ebd18c611b310905211f4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 27 Mar 2026 19:46:57 +0000 Subject: [PATCH 9/9] Rename TransportClosedException to ClientTransportClosedException Aligns with naming conventions: the exception carries ClientCompletionDetails, lives in the Client namespace, and follows the pattern of StdioClientTransport, HttpClientTransport, ClientCompletionDetails, etc. Agent-Logs-Url: https://github.com/modelcontextprotocol/csharp-sdk/sessions/5f35eb13-5319-4916-92da-3f9c7c253766 Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com> --- ...tion.cs => ClientTransportClosedException.cs} | 4 ++-- .../Client/McpClient.Methods.cs | 6 +++--- .../Client/SseClientSessionTransport.cs | 6 +++--- .../Client/StdioClientSessionTransport.cs | 4 ++-- .../StreamableHttpClientSessionTransport.cs | 6 +++--- .../McpSessionHandler.cs | 10 +++++----- .../Client/ClientCompletionDetailsTests.cs | 14 +++++++------- .../Client/McpClientCreationTests.cs | 16 ++++++++-------- 8 files changed, 33 insertions(+), 33 deletions(-) rename src/ModelContextProtocol.Core/Client/{TransportClosedException.cs => ClientTransportClosedException.cs} (90%) diff --git a/src/ModelContextProtocol.Core/Client/TransportClosedException.cs b/src/ModelContextProtocol.Core/Client/ClientTransportClosedException.cs similarity index 90% rename from src/ModelContextProtocol.Core/Client/TransportClosedException.cs rename to src/ModelContextProtocol.Core/Client/ClientTransportClosedException.cs index 2e5248491..611edf8a5 100644 --- a/src/ModelContextProtocol.Core/Client/TransportClosedException.cs +++ b/src/ModelContextProtocol.Core/Client/ClientTransportClosedException.cs @@ -4,7 +4,7 @@ namespace ModelContextProtocol.Client; /// -/// An that indicates the transport was closed, carrying +/// An that indicates the client transport was closed, carrying /// structured about why the closure occurred. /// /// @@ -25,7 +25,7 @@ namespace ModelContextProtocol.Client; /// with this exception. /// /// -public sealed class TransportClosedException(ClientCompletionDetails details) : +public sealed class ClientTransportClosedException(ClientCompletionDetails details) : IOException(details.Exception?.Message ?? "The transport was closed.", details.Exception) { /// diff --git a/src/ModelContextProtocol.Core/Client/McpClient.Methods.cs b/src/ModelContextProtocol.Core/Client/McpClient.Methods.cs index 498098d9b..00bdde075 100644 --- a/src/ModelContextProtocol.Core/Client/McpClient.Methods.cs +++ b/src/ModelContextProtocol.Core/Client/McpClient.Methods.cs @@ -53,7 +53,7 @@ public static async Task CreateAsync( { await clientSession.ConnectAsync(cancellationToken).ConfigureAwait(false); } - catch (Exception ex) when (ex is not OperationCanceledException and not TransportClosedException) + catch (Exception ex) when (ex is not OperationCanceledException and not ClientTransportClosedException) { // ConnectAsync already disposed the session (which includes awaiting Completion). // Check if the transport provided structured completion details indicating @@ -63,14 +63,14 @@ public static async Task CreateAsync( // 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 TransportClosedException with the structured details so + // 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)) { - throw new TransportClosedException(completionDetails); + throw new ClientTransportClosedException(completionDetails); } throw; diff --git a/src/ModelContextProtocol.Core/Client/SseClientSessionTransport.cs b/src/ModelContextProtocol.Core/Client/SseClientSessionTransport.cs index 65690fa36..fb918989b 100644 --- a/src/ModelContextProtocol.Core/Client/SseClientSessionTransport.cs +++ b/src/ModelContextProtocol.Core/Client/SseClientSessionTransport.cs @@ -129,7 +129,7 @@ private async Task CloseAsync() } finally { - SetDisconnected(new TransportClosedException(new HttpClientCompletionDetails())); + SetDisconnected(new ClientTransportClosedException(new HttpClientCompletionDetails())); } } @@ -190,7 +190,7 @@ private async Task ReceiveMessagesAsync(CancellationToken cancellationToken) } else { - SetDisconnected(new TransportClosedException(new HttpClientCompletionDetails + SetDisconnected(new ClientTransportClosedException(new HttpClientCompletionDetails { HttpStatusCode = failureStatusCode, Exception = ex, @@ -203,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/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/McpSessionHandler.cs b/src/ModelContextProtocol.Core/McpSessionHandler.cs index 22ab09b0f..80ef620bb 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,7 +325,7 @@ ex is OperationCanceledException && } // Fail any pending requests, as they'll never be satisfied. - // If the transport's channel was completed with a TransportClosedException, + // 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 && @@ -341,7 +341,7 @@ ex is OperationCanceledException && /// /// 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) @@ -351,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; } @@ -944,7 +944,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/tests/ModelContextProtocol.Tests/Client/ClientCompletionDetailsTests.cs b/tests/ModelContextProtocol.Tests/Client/ClientCompletionDetailsTests.cs index 36eae4365..64e240657 100644 --- a/tests/ModelContextProtocol.Tests/Client/ClientCompletionDetailsTests.cs +++ b/tests/ModelContextProtocol.Tests/Client/ClientCompletionDetailsTests.cs @@ -5,7 +5,7 @@ namespace ModelContextProtocol.Tests.Client; public class ClientCompletionDetailsTests { [Fact] - public void TransportClosedException_ExposesDetails() + public void ClientTransportClosedException_ExposesDetails() { var details = new StdioClientCompletionDetails { @@ -15,7 +15,7 @@ public void TransportClosedException_ExposesDetails() Exception = new IOException("process exited"), }; - var exception = new TransportClosedException(details); + var exception = new ClientTransportClosedException(details); Assert.IsType(exception.Details); var stdioDetails = (StdioClientCompletionDetails)exception.Details; @@ -27,11 +27,11 @@ public void TransportClosedException_ExposesDetails() } [Fact] - public void TransportClosedException_WithNullException_HasDefaultMessage() + public void ClientTransportClosedException_WithNullException_HasDefaultMessage() { var details = new ClientCompletionDetails(); - var exception = new TransportClosedException(details); + var exception = new ClientTransportClosedException(details); Assert.Equal("The transport was closed.", exception.Message); Assert.Null(exception.InnerException); @@ -39,11 +39,11 @@ public void TransportClosedException_WithNullException_HasDefaultMessage() } [Fact] - public void TransportClosedException_IsIOException() + public void ClientTransportClosedException_IsIOException() { var details = new ClientCompletionDetails(); - IOException exception = new TransportClosedException(details); - Assert.IsType(exception); + IOException exception = new ClientTransportClosedException(details); + Assert.IsType(exception); } [Fact] diff --git a/tests/ModelContextProtocol.Tests/Client/McpClientCreationTests.cs b/tests/ModelContextProtocol.Tests/Client/McpClientCreationTests.cs index 19c8a2d76..b2935d247 100644 --- a/tests/ModelContextProtocol.Tests/Client/McpClientCreationTests.cs +++ b/tests/ModelContextProtocol.Tests/Client/McpClientCreationTests.cs @@ -104,16 +104,16 @@ public async Task CreateAsync_WithCapabilitiesOptions(Type transportType) } [Fact] - public async Task CreateAsync_TransportChannelClosed_ThrowsTransportClosedException() + public async Task CreateAsync_TransportChannelClosed_ThrowsClientTransportClosedException() { - // Arrange - transport completes its read channel with TransportClosedException + // 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( + var ex = await Assert.ThrowsAsync( () => McpClient.CreateAsync(transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken)); var details = Assert.IsType(ex.Details); @@ -132,8 +132,8 @@ public async Task CreateAsync_TransportChannelClosed_ThrowsTransportClosedExcept public async Task CreateAsync_SendFails_PropagatesOriginalIOException() { // Arrange - transport throws IOException from SendMessageAsync, but the channel - // is not completed with TransportClosedException. The original IOException should - // propagate without being wrapped in TransportClosedException. + // is not completed with ClientTransportClosedException. The original IOException should + // propagate without being wrapped in ClientTransportClosedException. var transport = new SendFailsDuringInitTransport(); // Act & Assert @@ -230,7 +230,7 @@ public ValueTask DisposeAsync() public Task SendMessageAsync(JsonRpcMessage message, CancellationToken cancellationToken = default) { // Simulate the server process exiting: complete the channel with a - // TransportClosedException carrying structured completion details. + // ClientTransportClosedException carrying structured completion details. // The send itself succeeds — the failure comes from the read side. var details = new StdioClientCompletionDetails { @@ -240,14 +240,14 @@ public Task SendMessageAsync(JsonRpcMessage message, CancellationToken cancellat Exception = new IOException("MCP server process exited unexpectedly (exit code: 42)"), }; - _channel.Writer.TryComplete(new TransportClosedException(details)); + _channel.Writer.TryComplete(new ClientTransportClosedException(details)); return Task.CompletedTask; } } /// /// Simulates a transport where SendMessageAsync throws IOException but the channel - /// doesn't carry a TransportClosedException (e.g., a write pipe break without structured details). + /// doesn't carry a ClientTransportClosedException (e.g., a write pipe break without structured details). /// private sealed class SendFailsDuringInitTransport : NopTransport {