Fix process crash when testing Stderr (#1449)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Eric StJohn 2026-04-06 11:29:00 -07:00 • committed by GitHub
commit 2e7209c35a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 254 additions and 148 deletions

View file

@ -10,15 +10,17 @@ internal sealed class StdioClientSessionTransport : StreamClientSessionTransport
private readonly StdioClientTransportOptions _options;
private readonly Process _process;
private readonly Queue<string> _stderrRollingLog;
private readonly DataReceivedEventHandler _errorHandler;
private int _cleanedUp = 0;
private readonly int? _processId;
public StdioClientSessionTransport(StdioClientTransportOptions options, Process process, string endpointName, Queue<string> stderrRollingLog, ILoggerFactory? loggerFactory) :
public StdioClientSessionTransport(StdioClientTransportOptions options, Process process, string endpointName, Queue<string> stderrRollingLog, DataReceivedEventHandler errorHandler, ILoggerFactory? loggerFactory) :
base(process.StandardInput.BaseStream, process.StandardOutput.BaseStream, encoding: null, endpointName, loggerFactory)
{
_options = options;
_process = process;
_stderrRollingLog = stderrRollingLog;
_errorHandler = errorHandler;
try { _processId = process.Id; } catch { }
}
@ -33,7 +35,7 @@ internal sealed class StdioClientSessionTransport : StreamClientSessionTransport
{
// We failed to send due to an I/O error. If the server process has exited, which is then very likely the cause
// for the I/O error, we should throw an exception for that instead.
if (await GetUnexpectedExitExceptionAsync(cancellationToken).ConfigureAwait(false) is Exception processExitException)
if (await GetUnexpectedExitExceptionAsync().ConfigureAwait(false) is Exception processExitException)
{
throw processExitException;
}
@ -45,15 +47,32 @@ internal sealed class StdioClientSessionTransport : StreamClientSessionTransport
/// <inheritdoc/>
protected override async ValueTask CleanupAsync(Exception? error = null, CancellationToken cancellationToken = default)
{
// Only clean up once.
// Only run the full stdio cleanup once (handler detach, process kill, etc.).
// If another call is already handling cleanup, cancel the shutdown token
// to unblock it (e.g. if it's stuck in WaitForExitAsync) and let it
// call SetDisconnected with full StdioClientCompletionDetails.
if (Interlocked.Exchange(ref _cleanedUp, 1) != 0)
{
CancelShutdown();
return;
}
// We've not yet forcefully terminated the server. If it's already shut down, something went wrong,
// so create an exception with details about that.
error ??= await GetUnexpectedExitExceptionAsync(cancellationToken).ConfigureAwait(false);
error ??= await GetUnexpectedExitExceptionAsync().ConfigureAwait(false);
// Ensure all pending ErrorDataReceived events are drained before detaching
// the handler. GetUnexpectedExitExceptionAsync does this when HasExited is
// true, but there is a narrow window on Linux where the process has closed
// stdout (causing EOF in ReadMessagesAsync) yet hasn't been fully reaped,
// so HasExited returns false and the drain is skipped. An unconditional
// wait here covers that gap. When the drain already happened above, the
// call returns immediately.
await WaitForProcessExitAsync().ConfigureAwait(false);
// Detach the stderr handler so no further ErrorDataReceived events
// are dispatched during or after process disposal.
_process.ErrorDataReceived -= _errorHandler;
// Terminate the server process (or confirm it already exited), then build
// and publish strongly-typed completion details while the process handle
@ -78,7 +97,7 @@ internal sealed class StdioClientSessionTransport : StreamClientSessionTransport
await base.CleanupAsync(error, cancellationToken).ConfigureAwait(false);
}
private async ValueTask<Exception?> GetUnexpectedExitExceptionAsync(CancellationToken cancellationToken)
private async ValueTask<Exception?> GetUnexpectedExitExceptionAsync()
{
if (!StdioClientTransport.HasExited(_process))
{
@ -86,18 +105,8 @@ internal sealed class StdioClientSessionTransport : StreamClientSessionTransport
}
Debug.Assert(StdioClientTransport.HasExited(_process));
try
{
// The process has exited, but we still need to ensure stderr has been flushed.
// WaitForExitAsync only waits for exit; it does not guarantee that all
// ErrorDataReceived events have been dispatched. The synchronous WaitForExit()
// (no arguments) does ensure that, so call it after WaitForExitAsync completes.
#if NET
await _process.WaitForExitAsync(cancellationToken).ConfigureAwait(false);
#endif
_process.WaitForExit();
}
catch { }
await WaitForProcessExitAsync().ConfigureAwait(false);
string errorMessage = "MCP server process exited unexpectedly";
@ -122,6 +131,35 @@ internal sealed class StdioClientSessionTransport : StreamClientSessionTransport
return new IOException(errorMessage);
}
/// <summary>
/// Waits for the process to exit within <see cref="StdioClientTransportOptions.ShutdownTimeout"/>
/// and flushes pending <see cref="Process.ErrorDataReceived"/> events.
/// </summary>
/// <remarks>
/// On .NET, <c>Process.WaitForExitAsync</c> also waits for asynchronous output readers
/// to complete, ensuring all events have been dispatched. On .NET Framework,
/// <see cref="Process.WaitForExit(int)"/> does not guarantee this; the parameterless
/// <see cref="Process.WaitForExit()"/> overload is needed to flush the event queue.
/// This method is idempotent—calling it after the process has already been waited on
/// returns immediately.
/// </remarks>
private async ValueTask WaitForProcessExitAsync()
{
try
{
#if NET
using var timeoutCts = new CancellationTokenSource(_options.ShutdownTimeout);
await _process.WaitForExitAsync(timeoutCts.Token).ConfigureAwait(false);
#else
if (_process.WaitForExit((int)_options.ShutdownTimeout.TotalMilliseconds))
{
_process.WaitForExit();
}
#endif
}
catch { }
}
private StdioClientCompletionDetails BuildCompletionDetails(Exception? error)
{
StdioClientCompletionDetails details = new()

View file

@ -59,6 +59,7 @@ public sealed partial class StdioClientTransport : IClientTransport
Process? process = null;
bool processStarted = false;
DataReceivedEventHandler? errorHandler = null;
string command = _options.Command;
IList<string>? arguments = _options.Arguments;
@ -136,7 +137,7 @@ public sealed partial class StdioClientTransport : IClientTransport
// few lines in a rolling log for use in exceptions.
const int MaxStderrLength = 10; // keep the last 10 lines of stderr
Queue<string> stderrRollingLog = new(MaxStderrLength);
process.ErrorDataReceived += (sender, args) =>
errorHandler = (sender, args) =>
{
string? data = args.Data;
if (data is not null)
@ -151,11 +152,22 @@ public sealed partial class StdioClientTransport : IClientTransport
stderrRollingLog.Enqueue(data);
}
_options.StandardErrorLines?.Invoke(data);
try
{
_options.StandardErrorLines?.Invoke(data);
}
catch (Exception ex)
{
// Prevent exceptions in the user callback from propagating
// to the background thread that dispatches ErrorDataReceived,
// which would crash the process.
LogStderrCallbackFailed(logger, endpointName, ex);
}
LogReadStderr(logger, endpointName, data);
}
};
process.ErrorDataReceived += errorHandler;
// We need both stdin and stdout to use a no-BOM UTF-8 encoding. On .NET Core,
// we can use ProcessStartInfo.StandardOutputEncoding/StandardInputEncoding, but
@ -193,7 +205,7 @@ public sealed partial class StdioClientTransport : IClientTransport
process.BeginErrorReadLine();
return new StdioClientSessionTransport(_options, process, endpointName, stderrRollingLog, _loggerFactory);
return new StdioClientSessionTransport(_options, process, endpointName, stderrRollingLog, errorHandler, _loggerFactory);
}
catch (Exception ex)
{
@ -201,6 +213,11 @@ public sealed partial class StdioClientTransport : IClientTransport
try
{
if (process is not null && errorHandler is not null)
{
process.ErrorDataReceived -= errorHandler;
}
DisposeProcess(process, processStarted, _options.ShutdownTimeout);
}
catch (Exception ex2)
@ -228,18 +245,6 @@ public sealed partial class StdioClientTransport : IClientTransport
process.KillTree(shutdownTimeout);
}
// Ensure all redirected stderr/stdout events have been dispatched
// before disposing. Only the no-arg WaitForExit() guarantees this;
// WaitForExit(int) (as used by KillTree) does not.
// This should not hang: either the process already exited on its own
// (no child processes holding handles), or KillTree killed the entire
// process tree. If it does take too long, the test infrastructure's
// own timeout will catch it.
if (!processRunning && HasExited(process))
{
process.WaitForExit();
}
// Invoke the callback while the process handle is still valid,
// e.g. to read ExitCode before Dispose() invalidates it.
beforeDispose?.Invoke();
@ -299,6 +304,9 @@ public sealed partial class StdioClientTransport : IClientTransport
[LoggerMessage(Level = LogLevel.Information, Message = "{EndpointName} received stderr log: '{Data}'.")]
private static partial void LogReadStderr(ILogger logger, string endpointName, string data);
[LoggerMessage(Level = LogLevel.Warning, Message = "{EndpointName} StandardErrorLines callback failed.")]
private static partial void LogStderrCallbackFailed(ILogger logger, string endpointName, Exception exception);
[LoggerMessage(Level = LogLevel.Information, Message = "{EndpointName} started server process with PID {ProcessId}.")]
private static partial void LogTransportProcessStarted(ILogger logger, string endpointName, int processId);

View file

@ -15,7 +15,7 @@ internal class StreamClientSessionTransport : TransportBase
private readonly TextReader _serverOutput;
private readonly Stream _serverInputStream;
private readonly SemaphoreSlim _sendLock = new(1, 1);
private CancellationTokenSource? _shutdownCts = new();
private readonly CancellationTokenSource _shutdownCts = new();
private Task? _readTask;
/// <summary>
@ -97,8 +97,16 @@ internal class StreamClientSessionTransport : TransportBase
}
/// <inheritdoc/>
public override ValueTask DisposeAsync() =>
CleanupAsync(cancellationToken: CancellationToken.None);
public override async ValueTask DisposeAsync()
{
await CleanupAsync(cancellationToken: CancellationToken.None).ConfigureAwait(false);
// Ensure the channel is always completed after disposal, even if CleanupAsync
// returned early because another caller (e.g. ReadMessagesAsync) was already
// running cleanup. SetDisconnected is idempotent—if the channel was already
// completed by the other cleanup path, this is a no-op.
SetDisconnected();
}
private async Task ReadMessagesAsync(CancellationToken cancellationToken)
{
@ -168,15 +176,20 @@ internal class StreamClientSessionTransport : TransportBase
}
}
/// <summary>
/// Cancels the shutdown token to signal that the transport is shutting down,
/// without performing any other cleanup.
/// </summary>
protected void CancelShutdown()
{
_shutdownCts.Cancel();
}
protected virtual async ValueTask CleanupAsync(Exception? error = null, CancellationToken cancellationToken = default)
{
LogTransportShuttingDown(Name);
if (Interlocked.Exchange(ref _shutdownCts, null) is { } shutdownCts)
{
await shutdownCts.CancelAsync().ConfigureAwait(false);
shutdownCts.Dispose();
}
await _shutdownCts.CancelAsync().ConfigureAwait(false);
if (Interlocked.Exchange(ref _readTask, null) is Task readTask)
{

View file

@ -82,24 +82,29 @@ public class ClientConformanceTests
var process = new Process { StartInfo = startInfo };
process.OutputDataReceived += (sender, e) =>
// Protect callbacks with try/catch to prevent ITestOutputHelper from
// throwing on a background thread if events arrive after the test completes.
DataReceivedEventHandler outputHandler = (sender, e) =>
{
if (e.Data != null)
{
_output.WriteLine(e.Data);
try { _output.WriteLine(e.Data); } catch { }
outputBuilder.AppendLine(e.Data);
}
};
process.ErrorDataReceived += (sender, e) =>
DataReceivedEventHandler errorHandler = (sender, e) =>
{
if (e.Data != null)
{
_output.WriteLine(e.Data);
try { _output.WriteLine(e.Data); } catch { }
errorBuilder.AppendLine(e.Data);
}
};
process.OutputDataReceived += outputHandler;
process.ErrorDataReceived += errorHandler;
process.Start();
process.BeginOutputReadLine();
process.BeginErrorReadLine();
@ -112,6 +117,8 @@ public class ClientConformanceTests
catch (OperationCanceledException)
{
process.Kill(entireProcessTree: true);
process.OutputDataReceived -= outputHandler;
process.ErrorDataReceived -= errorHandler;
return (
Success: false,
Output: outputBuilder.ToString(),
@ -119,6 +126,9 @@ public class ClientConformanceTests
);
}
process.OutputDataReceived -= outputHandler;
process.ErrorDataReceived -= errorHandler;
var output = outputBuilder.ToString();
var error = errorBuilder.ToString();
var success = process.ExitCode == 0 || HasOnlyWarnings(output, error);

View file

@ -136,24 +136,29 @@ public class ServerConformanceTests(ConformanceServerFixture fixture, ITestOutpu
var process = new Process { StartInfo = startInfo };
process.OutputDataReceived += (sender, e) =>
// Protect callbacks with try/catch to prevent ITestOutputHelper from
// throwing on a background thread if events arrive after the test completes.
DataReceivedEventHandler outputHandler = (sender, e) =>
{
if (e.Data != null)
{
output.WriteLine(e.Data);
try { output.WriteLine(e.Data); } catch { }
outputBuilder.AppendLine(e.Data);
}
};
process.ErrorDataReceived += (sender, e) =>
DataReceivedEventHandler errorHandler = (sender, e) =>
{
if (e.Data != null)
{
output.WriteLine(e.Data);
try { output.WriteLine(e.Data); } catch { }
errorBuilder.AppendLine(e.Data);
}
};
process.OutputDataReceived += outputHandler;
process.ErrorDataReceived += errorHandler;
process.Start();
process.BeginOutputReadLine();
process.BeginErrorReadLine();
@ -166,6 +171,8 @@ public class ServerConformanceTests(ConformanceServerFixture fixture, ITestOutpu
catch (OperationCanceledException)
{
process.Kill(entireProcessTree: true);
process.OutputDataReceived -= outputHandler;
process.ErrorDataReceived -= errorHandler;
return (
Success: false,
Output: outputBuilder.ToString(),
@ -173,6 +180,9 @@ public class ServerConformanceTests(ConformanceServerFixture fixture, ITestOutpu
);
}
process.OutputDataReceived -= outputHandler;
process.ErrorDataReceived -= errorHandler;
return (
Success: process.ExitCode == 0,
Output: outputBuilder.ToString(),

View file

@ -105,85 +105,96 @@ var clientTransport = new HttpClientTransport(new()
OAuth = oauthOptions,
}, loggerFactory: consoleLoggerFactory);
await using var mcpClient = await McpClient.CreateAsync(clientTransport, options, loggerFactory: consoleLoggerFactory);
bool success = true;
switch (scenario)
try
{
case "tools_call":
{
var tools = await mcpClient.ListToolsAsync();
Console.WriteLine($"Available tools: {string.Join(", ", tools.Select(t => t.Name))}");
await using var mcpClient = await McpClient.CreateAsync(clientTransport, options, loggerFactory: consoleLoggerFactory);
// Call the "add_numbers" tool
var toolName = "add_numbers";
Console.WriteLine($"Calling tool: {toolName}");
var result = await mcpClient.CallToolAsync(toolName: toolName, arguments: new Dictionary<string, object?>
{
{ "a", 5 },
{ "b", 10 }
});
success &= !(result.IsError == true);
break;
}
case "elicitation-sep1034-client-defaults":
{
var tools = await mcpClient.ListToolsAsync();
Console.WriteLine($"Available tools: {string.Join(", ", tools.Select(t => t.Name))}");
var toolName = "test_client_elicitation_defaults";
Console.WriteLine($"Calling tool: {toolName}");
var result = await mcpClient.CallToolAsync(toolName: toolName, arguments: new Dictionary<string, object?>());
success &= !(result.IsError == true);
break;
}
case "sse-retry":
{
var tools = await mcpClient.ListToolsAsync();
Console.WriteLine($"Available tools: {string.Join(", ", tools.Select(t => t.Name))}");
var toolName = "test_reconnection";
Console.WriteLine($"Calling tool: {toolName}");
var result = await mcpClient.CallToolAsync(toolName: toolName, arguments: new Dictionary<string, object?>());
success &= !(result.IsError == true);
break;
}
case "auth/scope-step-up":
{
// Just testing that we can authenticate and list tools
var tools = await mcpClient.ListToolsAsync();
Console.WriteLine($"Available tools: {string.Join(", ", tools.Select(t => t.Name))}");
bool success = true;
// Call the "test_tool" tool
var toolName = "test-tool";
Console.WriteLine($"Calling tool: {toolName}");
var result = await mcpClient.CallToolAsync(toolName: toolName, arguments: new Dictionary<string, object?>
{
{ "foo", "bar" },
});
success &= !(result.IsError == true);
break;
}
case "auth/scope-retry-limit":
switch (scenario)
{
// Try to list tools - this triggers the auth flow that always fails with 403.
// The test validates the client doesn't retry indefinitely.
try
case "tools_call":
{
await mcpClient.ListToolsAsync();
var tools = await mcpClient.ListToolsAsync();
Console.WriteLine($"Available tools: {string.Join(", ", tools.Select(t => t.Name))}");
// Call the "add_numbers" tool
var toolName = "add_numbers";
Console.WriteLine($"Calling tool: {toolName}");
var result = await mcpClient.CallToolAsync(toolName: toolName, arguments: new Dictionary<string, object?>
{
{ "a", 5 },
{ "b", 10 }
});
success &= !(result.IsError == true);
break;
}
catch (Exception ex)
case "elicitation-sep1034-client-defaults":
{
Console.WriteLine($"Expected auth failure: {ex.Message}");
var tools = await mcpClient.ListToolsAsync();
Console.WriteLine($"Available tools: {string.Join(", ", tools.Select(t => t.Name))}");
var toolName = "test_client_elicitation_defaults";
Console.WriteLine($"Calling tool: {toolName}");
var result = await mcpClient.CallToolAsync(toolName: toolName, arguments: new Dictionary<string, object?>());
success &= !(result.IsError == true);
break;
}
break;
case "sse-retry":
{
var tools = await mcpClient.ListToolsAsync();
Console.WriteLine($"Available tools: {string.Join(", ", tools.Select(t => t.Name))}");
var toolName = "test_reconnection";
Console.WriteLine($"Calling tool: {toolName}");
var result = await mcpClient.CallToolAsync(toolName: toolName, arguments: new Dictionary<string, object?>());
success &= !(result.IsError == true);
break;
}
case "auth/scope-step-up":
{
// Just testing that we can authenticate and list tools
var tools = await mcpClient.ListToolsAsync();
Console.WriteLine($"Available tools: {string.Join(", ", tools.Select(t => t.Name))}");
// Call the "test_tool" tool
var toolName = "test-tool";
Console.WriteLine($"Calling tool: {toolName}");
var result = await mcpClient.CallToolAsync(toolName: toolName, arguments: new Dictionary<string, object?>
{
{ "foo", "bar" },
});
success &= !(result.IsError == true);
break;
}
case "auth/scope-retry-limit":
{
// Try to list tools - this triggers the auth flow that always fails with 403.
// The test validates the client doesn't retry indefinitely.
try
{
await mcpClient.ListToolsAsync();
}
catch (Exception ex)
{
Console.WriteLine($"Expected auth failure: {ex.Message}");
}
break;
}
default:
// No extra processing for other scenarios
break;
}
default:
// No extra processing for other scenarios
break;
// Exit code 0 on success, 1 on failure
return success ? 0 : 1;
}
catch (Exception ex)
{
// Report the error to stderr and exit with a non-zero code rather than
// crashing the process with an unhandled exception. An unhandled exception
// generates a crash dump which can abort the parent test host.
Console.Error.WriteLine($"Conformance client failed: {ex}");
return 1;
}
// Exit code 0 on success, 1 on failure
return success ? 0 : 1;
// Copied from ProtectedMcpClient sample
// Simulate a user opening the browser and logging in

View file

@ -9,16 +9,17 @@ namespace ModelContextProtocol.Tests.Configuration;
public class McpServerBuilderExtensionsTransportsTests
{
[Fact]
public void WithStdioServerTransport_Sets_Transport()
public void WithStdioServerTransport_Registers_Transport()
{
var services = new ServiceCollection();
services.AddMcpServer().WithStdioServerTransport();
var transportServiceType = services.FirstOrDefault(s => s.ServiceType == typeof(ITransport));
Assert.NotNull(transportServiceType);
var serviceProvider = services.BuildServiceProvider();
Assert.IsType<StdioServerTransport>(serviceProvider.GetRequiredService<ITransport>());
// Verify StdioServerTransport is registered for ITransport, but don't resolve it —
// doing so opens Console.OpenStandardInput() which permanently blocks a thread pool
// thread on the test host's stdin. StdioServerTransport should only be used in a
// dedicated child process, not in-process.
var transportDescriptor = services.FirstOrDefault(s => s.ServiceType == typeof(ITransport));
Assert.NotNull(transportDescriptor);
}
[Fact]

View file

@ -181,9 +181,10 @@ public class McpServerOptionsSetupTests
};
})
.WithResources<SimpleResourceType>()
.WithStdioServerTransport();
.WithStreamServerTransport(Stream.Null, Stream.Null);
var options = services.BuildServiceProvider().GetRequiredService<IOptions<McpServerOptions>>().Value;
await using var sp = services.BuildServiceProvider();
var options = sp.GetRequiredService<IOptions<McpServerOptions>>().Value;
// The options should preserve the user's manually set capabilities
Assert.NotNull(options.Capabilities?.Resources);

View file

@ -106,9 +106,9 @@ public class McpServerResourceCapabilityIssueReproTests : ClientServerTestBase
};
})
.WithResources<LiveResources>()
.WithStdioServerTransport();
.WithStreamServerTransport(Stream.Null, Stream.Null);
var serviceProvider = services.BuildServiceProvider();
await using var serviceProvider = services.BuildServiceProvider();
var mcpOptions = serviceProvider.GetRequiredService<IOptions<McpServerOptions>>().Value;
// Verify capabilities are preserved
@ -122,15 +122,15 @@ public class McpServerResourceCapabilityIssueReproTests : ClientServerTestBase
}
[Fact]
public void ResourcesCapability_IsCreated_WhenOnlyResourcesAreProvided()
public async Task ResourcesCapability_IsCreated_WhenOnlyResourcesAreProvided()
{
// Test that ResourcesCapability is created even without handlers or manual setting
var services = new ServiceCollection();
var builder = services.AddMcpServer()
.WithResources<LiveResources>()
.WithStdioServerTransport();
.WithStreamServerTransport(Stream.Null, Stream.Null);
var serviceProvider = services.BuildServiceProvider();
await using var serviceProvider = services.BuildServiceProvider();
var mcpOptions = serviceProvider.GetRequiredService<IOptions<McpServerOptions>>().Value;
// Resources are registered

View file

@ -15,29 +15,29 @@ public class McpServerLoggingLevelTests
}
[Fact]
public void CanCreateServerWithLoggingLevelHandler()
public async Task CanCreateServerWithLoggingLevelHandler()
{
var services = new ServiceCollection();
services.AddMcpServer()
.WithStdioServerTransport()
.WithStreamServerTransport(Stream.Null, Stream.Null)
.WithSetLoggingLevelHandler(async (ctx, ct) => new EmptyResult());
var provider = services.BuildServiceProvider();
await using var provider = services.BuildServiceProvider();
provider.GetRequiredService<McpServer>();
}
[Fact]
public void AddingLoggingLevelHandlerSetsLoggingCapability()
public async Task AddingLoggingLevelHandlerSetsLoggingCapability()
{
var services = new ServiceCollection();
services.AddMcpServer()
.WithStdioServerTransport()
.WithStreamServerTransport(Stream.Null, Stream.Null)
.WithSetLoggingLevelHandler(async (ctx, ct) => new EmptyResult());
var provider = services.BuildServiceProvider();
await using var provider = services.BuildServiceProvider();
var server = provider.GetRequiredService<McpServer>();
@ -46,12 +46,12 @@ public class McpServerLoggingLevelTests
}
[Fact]
public void ServerWithoutCallingLoggingLevelHandlerDoesNotSetLoggingCapability()
public async Task ServerWithoutCallingLoggingLevelHandlerDoesNotSetLoggingCapability()
{
var services = new ServiceCollection();
services.AddMcpServer()
.WithStdioServerTransport();
var provider = services.BuildServiceProvider();
.WithStreamServerTransport(Stream.Null, Stream.Null);
await using var provider = services.BuildServiceProvider();
var server = provider.GetRequiredService<McpServer>();
Assert.Null(server.ServerOptions.Capabilities?.Logging);
}

View file

@ -29,12 +29,12 @@ public partial class McpServerResourceTests
}
[Fact]
public void CanCreateServerWithResource()
public async Task CanCreateServerWithResource()
{
var services = new ServiceCollection();
services.AddMcpServer()
.WithStdioServerTransport()
.WithStreamServerTransport(Stream.Null, Stream.Null)
.WithListResourcesHandler(async (ctx, ct) =>
{
return new ListResourcesResult
@ -58,19 +58,19 @@ public partial class McpServerResourceTests
};
});
var provider = services.BuildServiceProvider();
await using var provider = services.BuildServiceProvider();
provider.GetRequiredService<McpServer>();
}
[Fact]
public void CanCreateServerWithResourceTemplates()
public async Task CanCreateServerWithResourceTemplates()
{
var services = new ServiceCollection();
services.AddMcpServer()
.WithStdioServerTransport()
.WithStreamServerTransport(Stream.Null, Stream.Null)
.WithListResourceTemplatesHandler(async (ctx, ct) =>
{
return new ListResourceTemplatesResult
@ -94,17 +94,17 @@ public partial class McpServerResourceTests
};
});
var provider = services.BuildServiceProvider();
await using var provider = services.BuildServiceProvider();
provider.GetRequiredService<McpServer>();
}
[Fact]
public void CreatingReadHandlerWithNoListHandlerSucceeds()
public async Task CreatingReadHandlerWithNoListHandlerSucceeds()
{
var services = new ServiceCollection();
services.AddMcpServer()
.WithStdioServerTransport()
.WithStreamServerTransport(Stream.Null, Stream.Null)
.WithReadResourceHandler(async (ctx, ct) =>
{
return new ReadResourceResult
@ -117,7 +117,7 @@ public partial class McpServerResourceTests
}]
};
});
var sp = services.BuildServiceProvider();
await using var sp = services.BuildServiceProvider();
sp.GetRequiredService<McpServer>();
}

View file

@ -59,6 +59,17 @@ public class StdioClientTransportTests(ITestOutputHelper testOutputHelper) : Log
Assert.Contains(id, sb.ToString());
}
[Fact(Skip = "Platform not supported by this test.", SkipUnless = nameof(IsStdErrCallbackSupported))]
public async Task CreateAsync_StdErrCallbackThrows_DoesNotCrashProcess()
{
StdioClientTransport transport = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ?
new(new() { Command = "cmd", Arguments = ["/c", "echo fail >&2 & exit /b 1"], StandardErrorLines = _ => throw new InvalidOperationException("boom") }, LoggerFactory) :
new(new() { Command = "sh", Arguments = ["-c", "echo fail >&2; exit 1"], StandardErrorLines = _ => throw new InvalidOperationException("boom") }, LoggerFactory);
// Should throw IOException for the failed server, not crash the host process.
await Assert.ThrowsAnyAsync<IOException>(() => McpClient.CreateAsync(transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken));
}
[Theory]
[InlineData(null)]
[InlineData("argument with spaces")]

View file

@ -27,8 +27,11 @@ public class StdioServerTransportTests : LoggedTest
[Fact]
public async Task Constructor_Should_Initialize_With_Valid_Parameters()
{
// Act
await using var transport = new StdioServerTransport(_serverOptions);
// Use StreamServerTransport with Stream.Null rather than StdioServerTransport.
// StdioServerTransport opens Console.OpenStandardInput() which permanently
// blocks a thread pool thread on the test host's stdin. StdioServerTransport
// should only be instantiated in a dedicated child process.
await using var transport = new StreamServerTransport(Stream.Null, Stream.Null, _serverOptions.ServerInfo?.Name);
// Assert
Assert.NotNull(transport);