Skip to content

Commit d3f9b54

Browse files
Copilotstephentoub
andauthored
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>
1 parent 40c6dc8 commit d3f9b54

5 files changed

Lines changed: 183 additions & 9 deletions

File tree

src/ModelContextProtocol.Core/Client/McpClient.Methods.cs

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,20 +52,67 @@ public static async Task<McpClient> CreateAsync(
5252
{
5353
await clientSession.ConnectAsync(cancellationToken).ConfigureAwait(false);
5454
}
55+
catch (Exception ex) when (ex is not TransportClosedException)
56+
{
57+
// ConnectAsync already disposed the session. Call DisposeAsync again (no-op)
58+
// to ensure cleanup, then check if the transport provided structured completion
59+
// details indicating why the transport closed.
60+
ClientCompletionDetails? completionDetails = null;
61+
try
62+
{
63+
await clientSession.DisposeAsync().ConfigureAwait(false);
64+
completionDetails = await clientSession.Completion.ConfigureAwait(false);
65+
}
66+
catch { } // allow the original exception to propagate if completion is unavailable
67+
68+
// If the transport closed with a non-graceful error (e.g., server process exited)
69+
// and the completion details carry an exception that's NOT already in the original
70+
// exception chain, throw a TransportClosedException with the structured details so
71+
// callers can programmatically inspect the closure reason (exit code, stderr, etc.).
72+
// When the same exception is already in the chain (e.g., HttpRequestException from
73+
// an HTTP transport), the original exception is more appropriate to re-throw.
74+
if (completionDetails?.Exception is { } detailsException &&
75+
!ExceptionChainContains(ex, detailsException))
76+
{
77+
throw new TransportClosedException(completionDetails);
78+
}
79+
80+
throw;
81+
}
5582
catch
5683
{
84+
// The exception is already a TransportClosedException (e.g., from
85+
// ProcessMessagesCoreAsync propagating channel completion details).
86+
// Just ensure cleanup and re-throw.
5787
try
5888
{
5989
await clientSession.DisposeAsync().ConfigureAwait(false);
6090
}
61-
catch { } // allow the original exception to propagate
91+
catch { }
6292

6393
throw;
6494
}
6595

6696
return clientSession;
6797
}
6898

99+
/// <summary>
100+
/// Returns <see langword="true"/> if <paramref name="target"/> is the same object as
101+
/// <paramref name="exception"/> or any exception in its <see cref="Exception.InnerException"/> chain.
102+
/// </summary>
103+
private static bool ExceptionChainContains(Exception exception, Exception target)
104+
{
105+
for (Exception? current = exception; current is not null; current = current.InnerException)
106+
{
107+
if (ReferenceEquals(current, target))
108+
{
109+
return true;
110+
}
111+
}
112+
113+
return false;
114+
}
115+
69116
/// <summary>
70117
/// Recreates an <see cref="McpClient"/> using an existing transport session without sending a new initialize request.
71118
/// </summary>

src/ModelContextProtocol.Core/Client/TransportClosedException.cs

Lines changed: 28 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,37 @@
44
namespace ModelContextProtocol.Client;
55

66
/// <summary>
7-
/// <see cref="IOException"/> used to smuggle <see cref="ClientCompletionDetails"/> through
8-
/// the <see cref="ChannelWriter{T}.TryComplete(Exception?)"/> mechanism.
7+
/// An <see cref="IOException"/> that indicates the transport was closed, carrying
8+
/// structured <see cref="ClientCompletionDetails"/> about why the closure occurred.
99
/// </summary>
1010
/// <remarks>
11-
/// This could be made public in the future to allow custom <see cref="ITransport"/>
12-
/// implementations to provide their own <see cref="ClientCompletionDetails"/>-derived types
13-
/// by completing their channel with this exception.
11+
/// <para>
12+
/// This exception is thrown when an MCP transport closes, either during initialization
13+
/// (e.g., from <see cref="McpClient.CreateAsync"/>) or during an active session.
14+
/// Callers can catch this exception to access the <see cref="Details"/> property
15+
/// for structured information about the closure.
16+
/// </para>
17+
/// <para>
18+
/// For stdio-based transports, the <see cref="Details"/> will be a
19+
/// <see cref="StdioClientCompletionDetails"/> instance providing access to the
20+
/// server process exit code, process ID, and standard error output.
21+
/// </para>
22+
/// <para>
23+
/// Custom <see cref="ITransport"/> implementations can provide their own
24+
/// <see cref="ClientCompletionDetails"/>-derived types by completing their
25+
/// <see cref="ChannelWriter{T}"/> with this exception.
26+
/// </para>
1427
/// </remarks>
15-
internal sealed class TransportClosedException(ClientCompletionDetails details) :
16-
IOException(details.Exception?.Message, details.Exception)
28+
public sealed class TransportClosedException(ClientCompletionDetails details) :
29+
IOException(details.Exception?.Message ?? "The transport was closed.", details.Exception)
1730
{
31+
/// <summary>
32+
/// Gets the structured details about why the transport was closed.
33+
/// </summary>
34+
/// <remarks>
35+
/// The concrete type of the returned <see cref="ClientCompletionDetails"/> depends on
36+
/// the transport that was used. For example, <see cref="StdioClientCompletionDetails"/>
37+
/// for stdio-based transports and <see cref="HttpClientCompletionDetails"/> for HTTP-based transports.
38+
/// </remarks>
1839
public ClientCompletionDetails Details { get; } = details;
1940
}

src/ModelContextProtocol.Core/McpSessionHandler.cs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -325,9 +325,15 @@ ex is OperationCanceledException &&
325325
}
326326

327327
// Fail any pending requests, as they'll never be satisfied.
328+
// If the transport's channel was completed with a TransportClosedException,
329+
// propagate it so callers can access the structured completion details.
330+
Exception pendingException =
331+
_transport.MessageReader.Completion is { IsCompleted: true, IsFaulted: true } completion
332+
? completion.Exception!.InnerException!
333+
: new IOException("The server shut down unexpectedly.");
328334
foreach (var entry in _pendingRequests)
329335
{
330-
entry.Value.TrySetException(new IOException("The server shut down unexpectedly."));
336+
entry.Value.TrySetException(pendingException);
331337
}
332338
}
333339
}

tests/ModelContextProtocol.Tests/Client/ClientCompletionDetailsTests.cs

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,48 @@ namespace ModelContextProtocol.Tests.Client;
44

55
public class ClientCompletionDetailsTests
66
{
7+
[Fact]
8+
public void TransportClosedException_ExposesDetails()
9+
{
10+
var details = new StdioClientCompletionDetails
11+
{
12+
ExitCode = 42,
13+
ProcessId = 12345,
14+
StandardErrorTail = ["error line"],
15+
Exception = new IOException("process exited"),
16+
};
17+
18+
var exception = new TransportClosedException(details);
19+
20+
Assert.IsType<StdioClientCompletionDetails>(exception.Details);
21+
var stdioDetails = (StdioClientCompletionDetails)exception.Details;
22+
Assert.Equal(42, stdioDetails.ExitCode);
23+
Assert.Equal(12345, stdioDetails.ProcessId);
24+
Assert.Equal(["error line"], stdioDetails.StandardErrorTail);
25+
Assert.Equal("process exited", exception.Message);
26+
Assert.IsType<IOException>(exception.InnerException);
27+
}
28+
29+
[Fact]
30+
public void TransportClosedException_WithNullException_HasDefaultMessage()
31+
{
32+
var details = new ClientCompletionDetails();
33+
34+
var exception = new TransportClosedException(details);
35+
36+
Assert.Equal("The transport was closed.", exception.Message);
37+
Assert.Null(exception.InnerException);
38+
Assert.Same(details, exception.Details);
39+
}
40+
41+
[Fact]
42+
public void TransportClosedException_IsIOException()
43+
{
44+
var details = new ClientCompletionDetails();
45+
IOException exception = new TransportClosedException(details);
46+
Assert.IsType<TransportClosedException>(exception);
47+
}
48+
749
[Fact]
850
public void ClientCompletionDetails_PropertiesRoundtrip()
951
{

tests/ModelContextProtocol.Tests/Client/McpClientCreationTests.cs

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,24 @@ public async Task CreateAsync_WithCapabilitiesOptions(Type transportType)
101101
}
102102
}
103103

104+
[Fact]
105+
public async Task CreateAsync_TransportClosedDuringInit_ThrowsTransportClosedException()
106+
{
107+
// Arrange - a transport that completes its channel with a TransportClosedException
108+
// when the client tries to send the initialize request (simulating a server process exit).
109+
var transport = new TransportClosedDuringInitTransport();
110+
111+
// Act & Assert
112+
var ex = await Assert.ThrowsAsync<TransportClosedException>(
113+
() => McpClient.CreateAsync(transport, cancellationToken: TestContext.Current.CancellationToken));
114+
115+
var details = Assert.IsType<StdioClientCompletionDetails>(ex.Details);
116+
Assert.Equal(42, details.ExitCode);
117+
Assert.Equal(9999, details.ProcessId);
118+
Assert.NotNull(details.StandardErrorTail);
119+
Assert.Equal("Feature disabled", details.StandardErrorTail![0]);
120+
}
121+
104122
private class NopTransport : ITransport, IClientTransport
105123
{
106124
private readonly Channel<JsonRpcMessage> _channel = Channel.CreateUnbounded<JsonRpcMessage>();
@@ -155,4 +173,44 @@ public override Task SendMessageAsync(JsonRpcMessage message, CancellationToken
155173
throw new InvalidOperationException(ExpectedMessage);
156174
}
157175
}
176+
177+
/// <summary>
178+
/// Simulates a transport that closes with structured completion details during initialization,
179+
/// as would happen when a stdio server process exits before completing the handshake.
180+
/// </summary>
181+
private sealed class TransportClosedDuringInitTransport : ITransport, IClientTransport
182+
{
183+
private readonly Channel<JsonRpcMessage> _channel = Channel.CreateUnbounded<JsonRpcMessage>();
184+
185+
public bool IsConnected => true;
186+
public string? SessionId => null;
187+
188+
public ChannelReader<JsonRpcMessage> MessageReader => _channel.Reader;
189+
190+
public Task<ITransport> ConnectAsync(CancellationToken cancellationToken = default) => Task.FromResult<ITransport>(this);
191+
192+
public ValueTask DisposeAsync()
193+
{
194+
_channel.Writer.TryComplete();
195+
return default;
196+
}
197+
198+
public string Name => "Test TransportClosed Transport";
199+
200+
public Task SendMessageAsync(JsonRpcMessage message, CancellationToken cancellationToken = default)
201+
{
202+
// Simulate the server process exiting: complete the channel with a TransportClosedException
203+
// carrying structured completion details, then throw IOException like the real transport does.
204+
var details = new StdioClientCompletionDetails
205+
{
206+
ExitCode = 42,
207+
ProcessId = 9999,
208+
StandardErrorTail = ["Feature disabled"],
209+
Exception = new IOException("MCP server process exited unexpectedly (exit code: 42)"),
210+
};
211+
212+
_channel.Writer.TryComplete(new TransportClosedException(details));
213+
throw new IOException("Failed to send message.", new IOException("Broken pipe"));
214+
}
215+
}
158216
}

0 commit comments

Comments
 (0)