diff --git a/src/ModelContextProtocol.Core/Client/StdioClientSessionTransport.cs b/src/ModelContextProtocol.Core/Client/StdioClientSessionTransport.cs index caee6b383..87a7403f9 100644 --- a/src/ModelContextProtocol.Core/Client/StdioClientSessionTransport.cs +++ b/src/ModelContextProtocol.Core/Client/StdioClientSessionTransport.cs @@ -61,6 +61,14 @@ protected override async ValueTask CleanupAsync(Exception? error = null, Cancell // so create an exception with details about that. error ??= await GetUnexpectedExitExceptionAsync().ConfigureAwait(false); + // Closing the server's stdin is the portable graceful-shutdown signal for stdio transports. + // Do this before waiting so a well-behaved server can finish its own cleanup and exit. + try + { + _process.StandardInput.Close(); + } + catch { } + // 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 diff --git a/tests/ModelContextProtocol.Tests/Transport/StdioClientTransportTests.cs b/tests/ModelContextProtocol.Tests/Transport/StdioClientTransportTests.cs index 60ce9cf5a..73fd65d09 100644 --- a/tests/ModelContextProtocol.Tests/Transport/StdioClientTransportTests.cs +++ b/tests/ModelContextProtocol.Tests/Transport/StdioClientTransportTests.cs @@ -13,6 +13,43 @@ public class StdioClientTransportTests(ITestOutputHelper testOutputHelper) : Log { public static bool IsStdErrCallbackSupported => !PlatformDetection.IsMonoRuntime; + [Fact] + public async Task DisposeAsync_ClosesServerStandardInputForGracefulExit() + { + TimeSpan shutdownTimeout = TimeSpan.FromSeconds(4); + string testServerExecutable = Path.Combine(AppContext.BaseDirectory, "TestServer.exe"); + string testServerDll = Path.Combine(AppContext.BaseDirectory, "TestServer.dll"); + + StdioClientTransport transport = new(new() + { + Name = "TestServer", + Command = (PlatformDetection.IsMonoRuntime, PlatformDetection.IsWindows) switch + { + (true, _) => "mono", + (_, true) => testServerExecutable, + _ => "dotnet", + }, + Arguments = (PlatformDetection.IsMonoRuntime, PlatformDetection.IsWindows) switch + { + (true, _) => [testServerExecutable], + (_, true) => [], + _ => [testServerDll], + }, + ShutdownTimeout = shutdownTimeout, + }, LoggerFactory); + + await using ITransport session = await transport.ConnectAsync(TestContext.Current.CancellationToken); + + await session.DisposeAsync(); + + var exception = await Assert.ThrowsAsync( + async () => await session.MessageReader.Completion); + var completionDetails = Assert.IsType(exception.Details); + // A zero exit code proves the server observed stdin EOF and exited on its own + // instead of being terminated after ShutdownTimeout. + Assert.Equal(0, completionDetails.ExitCode); + } + [Fact] public async Task ConnectAsync_DoesNotLogEnvironmentVariablesAtTrace() {