Skip to content

Commit 4983cfb

Browse files
Copilothalter73
andcommitted
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>
1 parent 8308c3b commit 4983cfb

File tree

1 file changed

+58
-13
lines changed

1 file changed

+58
-13
lines changed

tests/ModelContextProtocol.Tests/Client/McpClientCreationTests.cs

Lines changed: 58 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
1+
using Microsoft.Extensions.Logging;
12
using ModelContextProtocol.Client;
23
using ModelContextProtocol.Protocol;
4+
using ModelContextProtocol.Tests.Utils;
35
using System.IO.Pipelines;
46
using System.Text.Json;
57
using System.Threading.Channels;
68

79
namespace 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

Comments
 (0)