Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
36 changes: 32 additions & 4 deletions src/ModelContextProtocol.Core/Client/McpClient.Methods.cs
Original file line number Diff line number Diff line change
Expand Up @@ -52,20 +52,48 @@ public static async Task<McpClient> CreateAsync(
{
await clientSession.ConnectAsync(cancellationToken).ConfigureAwait(false);
}
catch
catch (Exception ex) when (ex is not OperationCanceledException and not TransportClosedException)
{
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.
var completionDetails = await clientSession.Completion.ConfigureAwait(false);
Comment thread
stephentoub marked this conversation as resolved.

// 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))
{
await clientSession.DisposeAsync().ConfigureAwait(false);
throw new TransportClosedException(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)
{
Comment thread
stephentoub marked this conversation as resolved.
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
35 changes: 28 additions & 7 deletions src/ModelContextProtocol.Core/Client/TransportClosedException.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,37 @@
namespace ModelContextProtocol.Client;

/// <summary>
/// <see cref="IOException"/> used to smuggle <see cref="ClientCompletionDetails"/> through
/// the <see cref="ChannelWriter{T}.TryComplete(Exception?)"/> mechanism.
/// An <see cref="IOException"/> that indicates the transport was closed, carrying
/// structured <see cref="ClientCompletionDetails"/> about why the closure occurred.
/// </summary>
/// <remarks>
/// This could be made public in the future to allow custom <see cref="ITransport"/>
/// implementations to provide their own <see cref="ClientCompletionDetails"/>-derived types
/// by completing their channel with this exception.
/// <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>
internal sealed class TransportClosedException(ClientCompletionDetails details) :
IOException(details.Exception?.Message, details.Exception)
public sealed class TransportClosedException(ClientCompletionDetails details) :
Comment thread
halter73 marked this conversation as resolved.
Outdated
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;
}
9 changes: 8 additions & 1 deletion src/ModelContextProtocol.Core/McpSessionHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -325,9 +325,16 @@ 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 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);
}
}
}
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 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<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 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<TransportClosedException>(exception);
}

[Fact]
public void ClientCompletionDetails_PropertiesRoundtrip()
{
Expand Down
58 changes: 58 additions & 0 deletions tests/ModelContextProtocol.Tests/Client/McpClientCreationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<TransportClosedException>(
() => McpClient.CreateAsync(transport, cancellationToken: TestContext.Current.CancellationToken));

var details = Assert.IsType<StdioClientCompletionDetails>(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<JsonRpcMessage> _channel = Channel.CreateUnbounded<JsonRpcMessage>();
Expand Down Expand Up @@ -155,4 +173,44 @@ public override Task SendMessageAsync(JsonRpcMessage message, CancellationToken
throw new InvalidOperationException(ExpectedMessage);
}
}

/// <summary>
/// Simulates a transport that closes with structured completion details during initialization,
/// as would happen when a stdio server process exits before completing the handshake.
/// </summary>
private sealed class TransportClosedDuringInitTransport : ITransport, IClientTransport
{
private readonly Channel<JsonRpcMessage> _channel = Channel.CreateUnbounded<JsonRpcMessage>();

public bool IsConnected => true;
public string? SessionId => null;

public ChannelReader<JsonRpcMessage> MessageReader => _channel.Reader;

public Task<ITransport> ConnectAsync(CancellationToken cancellationToken = default) => Task.FromResult<ITransport>(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"));
Comment thread
stephentoub marked this conversation as resolved.
Outdated
}
}
}
Loading