Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
using ModelContextProtocol.Protocol;
using System.Threading.Channels;

namespace ModelContextProtocol.Client;

/// <summary>
/// An <see cref="IOException"/> that indicates the client transport was closed, carrying
/// structured <see cref="ClientCompletionDetails"/> about why the closure occurred.
/// </summary>
/// <remarks>
/// <para>
/// This exception is thrown when an MCP transport closes, either during initialization
/// (e.g., from <see cref="McpClient.CreateAsync"/>) or during an active session.
/// Callers can catch this exception to access the <see cref="Details"/> property
/// for structured information about the closure.
/// </para>
/// <para>
/// For stdio-based transports, the <see cref="Details"/> will be a
/// <see cref="StdioClientCompletionDetails"/> instance providing access to the
/// server process exit code, process ID, and standard error output.
/// </para>
/// <para>
/// Custom <see cref="ITransport"/> implementations can provide their own
/// <see cref="ClientCompletionDetails"/>-derived types by completing their
/// <see cref="ChannelWriter{T}"/> with this exception.
/// </para>
/// </remarks>
public sealed class ClientTransportClosedException(ClientCompletionDetails details) :
IOException(details.Exception?.Message ?? "The transport was closed.", details.Exception)
{
/// <summary>
/// Gets the structured details about why the transport was closed.
/// </summary>
/// <remarks>
/// The concrete type of the returned <see cref="ClientCompletionDetails"/> depends on
/// the transport that was used. For example, <see cref="StdioClientCompletionDetails"/>
/// for stdio-based transports and <see cref="HttpClientCompletionDetails"/> for HTTP-based transports.
/// </remarks>
public ClientCompletionDetails Details { get; } = details;
}
38 changes: 34 additions & 4 deletions src/ModelContextProtocol.Core/Client/McpClient.Methods.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -52,20 +53,49 @@ public static async Task<McpClient> 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;
}

return clientSession;
}

/// <summary>
/// Returns <see langword="true"/> if <paramref name="target"/> is the same object as
/// <paramref name="exception"/> or any exception in its <see cref="Exception.InnerException"/> chain.
/// </summary>
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;
}

/// <summary>
/// Recreates an <see cref="McpClient"/> using an existing transport session without sending a new initialize request.
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}

Expand Down Expand Up @@ -125,7 +129,7 @@ private async Task CloseAsync()
}
finally
{
SetDisconnected(new TransportClosedException(new HttpClientCompletionDetails()));
SetDisconnected(new ClientTransportClosedException(new HttpClientCompletionDetails()));
}
}

Expand Down Expand Up @@ -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,
Expand All @@ -199,7 +203,7 @@ private async Task ReceiveMessagesAsync(CancellationToken cancellationToken)
}
finally
{
SetDisconnected(new TransportClosedException(new HttpClientCompletionDetails()));
SetDisconnected(new ClientTransportClosedException(new HttpClientCompletionDetails()));
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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()));
}
}
}
Expand Down Expand Up @@ -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(
Expand Down
19 changes: 0 additions & 19 deletions src/ModelContextProtocol.Core/Client/TransportClosedException.cs

This file was deleted.

17 changes: 12 additions & 5 deletions src/ModelContextProtocol.Core/McpSessionHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,7 @@ public McpSessionHandler(
/// <summary>
/// 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 <see cref="TransportClosedException"/>, the wrapped
/// completes its channel with a <see cref="ClientTransportClosedException"/>, the wrapped
/// <see cref="ClientCompletionDetails"/> is unwrapped. Otherwise, a default instance is returned.
/// </summary>
internal Task<ClientCompletionDetails> CompletionTask =>
Expand Down Expand Up @@ -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);
}
}
}

/// <summary>
/// Resolves <see cref="ClientCompletionDetails"/> from the transport's channel completion.
/// If the channel was completed with a <see cref="TransportClosedException"/>, the wrapped
/// If the channel was completed with a <see cref="ClientTransportClosedException"/>, the wrapped
/// details are returned. Otherwise a default instance is created from the completion state.
/// </summary>
private static async Task<ClientCompletionDetails> GetCompletionDetailsAsync(Task channelCompletion)
Expand All @@ -344,7 +351,7 @@ private static async Task<ClientCompletionDetails> GetCompletionDetailsAsync(Tas
await channelCompletion.ConfigureAwait(false);
return new ClientCompletionDetails();
}
catch (TransportClosedException tce)
catch (ClientTransportClosedException tce)
{
return tce.Details;
}
Expand Down Expand Up @@ -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.
}
}
Expand Down
4 changes: 4 additions & 0 deletions src/ModelContextProtocol.Core/Server/StreamServerTransport.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<StdioClientCompletionDetails>(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<IOException>(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<ClientTransportClosedException>(exception);
}

[Fact]
public void ClientCompletionDetails_PropertiesRoundtrip()
{
Expand Down
Loading
Loading