1+ using Microsoft . Extensions . Logging ;
12using ModelContextProtocol . Client ;
23using ModelContextProtocol . Protocol ;
4+ using ModelContextProtocol . Tests . Utils ;
35using System . IO . Pipelines ;
46using System . Text . Json ;
57using System . Threading . Channels ;
68
79namespace ModelContextProtocol . Tests . Client ;
810
9- public class McpClientCreationTests
11+ public class McpClientCreationTests ( ITestOutputHelper testOutputHelper ) : LoggedTest ( testOutputHelper )
1012{
1113 [ Fact ]
1214 public async Task CreateAsync_WithInvalidArgs_Throws ( )
@@ -102,21 +104,48 @@ public async Task CreateAsync_WithCapabilitiesOptions(Type transportType)
102104 }
103105
104106 [ Fact ]
105- public async Task CreateAsync_TransportClosedDuringInit_ThrowsTransportClosedException ( )
107+ public async Task CreateAsync_TransportChannelClosed_ThrowsTransportClosedException ( )
106108 {
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 ( ) ;
109+ // Arrange - transport completes its read channel with TransportClosedException
110+ // when the client tries to send the initialize request (simulating a server process
111+ // exit detected by the reader loop). SendMessageAsync returns successfully —
112+ // only the read side fails.
113+ var transport = new ChannelClosedDuringInitTransport ( ) ;
110114
111115 // Act & Assert
112116 var ex = await Assert . ThrowsAsync < TransportClosedException > (
113- ( ) => McpClient . CreateAsync ( transport , cancellationToken : TestContext . Current . CancellationToken ) ) ;
117+ ( ) => McpClient . CreateAsync ( transport , loggerFactory : LoggerFactory , cancellationToken : TestContext . Current . CancellationToken ) ) ;
114118
115119 var details = Assert . IsType < StdioClientCompletionDetails > ( ex . Details ) ;
116120 Assert . Equal ( 42 , details . ExitCode ) ;
117121 Assert . Equal ( 9999 , details . ProcessId ) ;
118122 Assert . NotNull ( details . StandardErrorTail ) ;
119123 Assert . Equal ( "Feature disabled" , details . StandardErrorTail ! [ 0 ] ) ;
124+
125+ // Verify initialization error was logged
126+ Assert . Contains ( MockLoggerProvider . LogMessages , log =>
127+ log . LogLevel == LogLevel . Error &&
128+ log . Message . Contains ( "client initialization error" ) ) ;
129+ }
130+
131+ [ Fact ]
132+ public async Task CreateAsync_SendFails_PropagatesOriginalIOException ( )
133+ {
134+ // Arrange - transport throws IOException from SendMessageAsync, but the channel
135+ // is not completed with TransportClosedException. The original IOException should
136+ // propagate without being wrapped in TransportClosedException.
137+ var transport = new SendFailsDuringInitTransport ( ) ;
138+
139+ // Act & Assert
140+ var ex = await Assert . ThrowsAsync < IOException > (
141+ ( ) => McpClient . CreateAsync ( transport , loggerFactory : LoggerFactory , cancellationToken : TestContext . Current . CancellationToken ) ) ;
142+
143+ Assert . Equal ( SendFailsDuringInitTransport . ExpectedMessage , ex . Message ) ;
144+
145+ // Verify initialization error was logged
146+ Assert . Contains ( MockLoggerProvider . LogMessages , log =>
147+ log . LogLevel == LogLevel . Error &&
148+ log . Message . Contains ( "client initialization error" ) ) ;
120149 }
121150
122151 private class NopTransport : ITransport , IClientTransport
@@ -175,10 +204,11 @@ public override Task SendMessageAsync(JsonRpcMessage message, CancellationToken
175204 }
176205
177206 /// <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.
207+ /// Simulates a transport where the read channel closes with structured completion details during
208+ /// initialization, as happens when a stdio server process exits before completing the handshake.
209+ /// The send succeeds — only the read side carries the failure.
180210 /// </summary>
181- private sealed class TransportClosedDuringInitTransport : ITransport , IClientTransport
211+ private sealed class ChannelClosedDuringInitTransport : ITransport , IClientTransport
182212 {
183213 private readonly Channel < JsonRpcMessage > _channel = Channel . CreateUnbounded < JsonRpcMessage > ( ) ;
184214
@@ -195,12 +225,13 @@ public ValueTask DisposeAsync()
195225 return default ;
196226 }
197227
198- public string Name => "Test TransportClosed Transport" ;
228+ public string Name => "Test ChannelClosed Transport" ;
199229
200230 public Task SendMessageAsync ( JsonRpcMessage message , CancellationToken cancellationToken = default )
201231 {
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.
232+ // Simulate the server process exiting: complete the channel with a
233+ // TransportClosedException carrying structured completion details.
234+ // The send itself succeeds — the failure comes from the read side.
204235 var details = new StdioClientCompletionDetails
205236 {
206237 ExitCode = 42 ,
@@ -210,7 +241,21 @@ public Task SendMessageAsync(JsonRpcMessage message, CancellationToken cancellat
210241 } ;
211242
212243 _channel . Writer . TryComplete ( new TransportClosedException ( details ) ) ;
213- throw new IOException ( "Failed to send message." , new IOException ( "Broken pipe" ) ) ;
244+ return Task . CompletedTask ;
245+ }
246+ }
247+
248+ /// <summary>
249+ /// Simulates a transport where SendMessageAsync throws IOException but the channel
250+ /// doesn't carry a TransportClosedException (e.g., a write pipe break without structured details).
251+ /// </summary>
252+ private sealed class SendFailsDuringInitTransport : NopTransport
253+ {
254+ public const string ExpectedMessage = "Failed to write to transport" ;
255+
256+ public override Task SendMessageAsync ( JsonRpcMessage message , CancellationToken cancellationToken = default )
257+ {
258+ throw new IOException ( ExpectedMessage ) ;
214259 }
215260 }
216261}
0 commit comments