Skip to content

Commit 031cae4

Browse files
committed
Improve exception diagnostics for stdio client
Track the last several lines of stderr and include those in an exception created as part of CleanupAsync if the server process has already exited. This is based on an assumption that the server should never exit prior to CleanupAsync being called.
1 parent b743889 commit 031cae4

File tree

10 files changed

+202
-127
lines changed

10 files changed

+202
-127
lines changed

src/Common/Polyfills/System/Diagnostics/ProcessExtensions.cs

Lines changed: 0 additions & 37 deletions
This file was deleted.

src/ModelContextProtocol/Protocol/Transport/SseClientSessionTransport.cs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -137,7 +137,7 @@ private async Task CloseAsync()
137137
}
138138
finally
139139
{
140-
SetConnected(false);
140+
SetDisconnected();
141141
}
142142
}
143143

@@ -203,7 +203,7 @@ private async Task ReceiveMessagesAsync(CancellationToken cancellationToken)
203203
}
204204
finally
205205
{
206-
SetConnected(false);
206+
SetDisconnected();
207207
}
208208
}
209209

@@ -251,7 +251,7 @@ private void HandleEndpointEvent(string data)
251251
_messageEndpoint = new Uri(_sseEndpoint, data);
252252

253253
// Set connected state
254-
SetConnected(true);
254+
SetConnected();
255255
_connectionEstablished.TrySetResult(true);
256256
}
257257

Lines changed: 61 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
using Microsoft.Extensions.Logging;
22
using ModelContextProtocol.Protocol.Messages;
3-
using System;
43
using System.Diagnostics;
54

65
namespace ModelContextProtocol.Protocol.Transport;
@@ -10,54 +9,51 @@ internal sealed class StdioClientSessionTransport : StreamClientSessionTransport
109
{
1110
private readonly StdioClientTransportOptions _options;
1211
private readonly Process _process;
12+
private readonly Queue<string> _stderrRollingLog;
13+
private int _cleanedUp = 0;
1314

14-
public StdioClientSessionTransport(StdioClientTransportOptions options, Process process, string endpointName, ILoggerFactory? loggerFactory)
15+
public StdioClientSessionTransport(StdioClientTransportOptions options, Process process, string endpointName, Queue<string> stderrRollingLog, ILoggerFactory? loggerFactory)
1516
: base(process.StandardInput, process.StandardOutput, endpointName, loggerFactory)
1617
{
1718
_process = process;
1819
_options = options;
20+
_stderrRollingLog = stderrRollingLog;
1921
}
2022

2123
/// <inheritdoc/>
22-
/// <remarks>
23-
/// <para>
24-
/// For stdio-based transports, this implementation first verifies that the underlying process
25-
/// is still running before attempting to send the message. If the process has exited or cannot
26-
/// be accessed, a <see cref="InvalidOperationException"/> is thrown with details about the failure.
27-
/// </para>
28-
/// <para>
29-
/// After verifying the process state, this method delegates to the base class implementation
30-
/// to handle the actual message serialization and transmission to the process's standard input stream.
31-
/// </para>
32-
/// </remarks>
33-
/// <exception cref="InvalidOperationException">
34-
/// Thrown when the underlying process has exited or cannot be accessed.
35-
/// </exception>
3624
public override async Task SendMessageAsync(JsonRpcMessage message, CancellationToken cancellationToken = default)
3725
{
38-
Exception? processException = null;
39-
bool hasExited = false;
4026
try
4127
{
42-
hasExited = _process.HasExited;
28+
await base.SendMessageAsync(message, cancellationToken);
4329
}
44-
catch (Exception e)
30+
catch (IOException)
4531
{
46-
processException = e;
47-
hasExited = true;
48-
}
32+
// We failed to send due to an I/O error. If the server process has exited, which is then very likely the cause
33+
// for the I/O error, we should throw an exception for that instead.
34+
if (await GetUnexpectedExitExceptionAsync(cancellationToken).ConfigureAwait(false) is Exception processExitException)
35+
{
36+
throw processExitException;
37+
}
4938

50-
if (hasExited)
51-
{
52-
throw new InvalidOperationException("Transport is not connected", processException);
39+
throw;
5340
}
54-
55-
await base.SendMessageAsync(message, cancellationToken).ConfigureAwait(false);
5641
}
5742

5843
/// <inheritdoc/>
59-
protected override ValueTask CleanupAsync(CancellationToken cancellationToken)
44+
protected override async ValueTask CleanupAsync(Exception? error = null, CancellationToken cancellationToken = default)
6045
{
46+
// Only clean up once.
47+
if (Interlocked.Exchange(ref _cleanedUp, 1) != 0)
48+
{
49+
return;
50+
}
51+
52+
// We've not yet forcefully terminated the server. If it's already shut down, something went wrong,
53+
// so create an exception with details about that.
54+
error ??= await GetUnexpectedExitExceptionAsync(cancellationToken).ConfigureAwait(false);
55+
56+
// Now terminate the server process.
6157
try
6258
{
6359
StdioClientTransport.DisposeProcess(_process, processRunning: true, _options.ShutdownTimeout, Name);
@@ -67,6 +63,41 @@ protected override ValueTask CleanupAsync(CancellationToken cancellationToken)
6763
LogTransportShutdownFailed(Name, ex);
6864
}
6965

70-
return base.CleanupAsync(cancellationToken);
66+
// And handle cleanup in the base type.
67+
await base.CleanupAsync(error, cancellationToken);
68+
}
69+
70+
private async ValueTask<Exception?> GetUnexpectedExitExceptionAsync(CancellationToken cancellationToken)
71+
{
72+
if (!StdioClientTransport.HasExited(_process))
73+
{
74+
return null;
75+
}
76+
77+
Debug.Assert(StdioClientTransport.HasExited(_process));
78+
try
79+
{
80+
// The process has exited, but we still need to ensure stderr has been flushed.
81+
#if NET
82+
await _process.WaitForExitAsync(cancellationToken).ConfigureAwait(false);
83+
#else
84+
_process.WaitForExit();
85+
#endif
86+
}
87+
catch { }
88+
89+
string errorMessage = "MCP server process exited unexpectedly.";
90+
lock (_stderrRollingLog)
91+
{
92+
if (_stderrRollingLog.Count > 0)
93+
{
94+
errorMessage =
95+
$"{errorMessage}{Environment.NewLine}" +
96+
$"Server's stderr tail:{Environment.NewLine}" +
97+
$"{string.Join(Environment.NewLine, _stderrRollingLog)}";
98+
}
99+
}
100+
101+
return new IOException(errorMessage);
71102
}
72103
}

src/ModelContextProtocol/Protocol/Transport/StdioClientTransport.cs

Lines changed: 39 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -127,8 +127,28 @@ public async Task<ITransport> ConnectAsync(CancellationToken cancellationToken =
127127

128128
process = new() { StartInfo = startInfo };
129129

130-
// Set up error logging
131-
process.ErrorDataReceived += (sender, args) => LogReadStderr(logger, endpointName, args.Data ?? "(no data)");
130+
// Set up stderr handling. Log all stderr output, and keep the last
131+
// few lines in a rolling log for use in exceptions.
132+
const int MaxStderrLength = 10; // keep the last 10 lines of stderr
133+
Queue<string> stderrRollingLog = new(MaxStderrLength);
134+
process.ErrorDataReceived += (sender, args) =>
135+
{
136+
string? data = args.Data;
137+
if (data is not null)
138+
{
139+
lock (stderrRollingLog)
140+
{
141+
if (stderrRollingLog.Count >= MaxStderrLength)
142+
{
143+
stderrRollingLog.Dequeue();
144+
}
145+
146+
stderrRollingLog.Enqueue(data);
147+
}
148+
149+
LogReadStderr(logger, endpointName, data);
150+
}
151+
};
132152

133153
// We need both stdin and stdout to use a no-BOM UTF-8 encoding. On .NET Core,
134154
// we can use ProcessStartInfo.StandardOutputEncoding/StandardInputEncoding, but
@@ -154,14 +174,14 @@ public async Task<ITransport> ConnectAsync(CancellationToken cancellationToken =
154174
if (!processStarted)
155175
{
156176
LogTransportProcessStartFailed(logger, endpointName);
157-
throw new InvalidOperationException("Failed to start MCP server process");
177+
throw new IOException("Failed to start MCP server process.");
158178
}
159179

160180
LogTransportProcessStarted(logger, endpointName, process.Id);
161181

162182
process.BeginErrorReadLine();
163183

164-
return new StdioClientSessionTransport(_options, process, endpointName, _loggerFactory);
184+
return new StdioClientSessionTransport(_options, process, endpointName, stderrRollingLog, _loggerFactory);
165185
}
166186
catch (Exception ex)
167187
{
@@ -176,7 +196,7 @@ public async Task<ITransport> ConnectAsync(CancellationToken cancellationToken =
176196
LogTransportShutdownFailed(logger, endpointName, ex2);
177197
}
178198

179-
throw new InvalidOperationException("Failed to connect transport", ex);
199+
throw new IOException("Failed to connect transport.", ex);
180200
}
181201
}
182202

@@ -185,20 +205,9 @@ internal static void DisposeProcess(
185205
{
186206
if (process is not null)
187207
{
188-
if (processRunning)
189-
{
190-
try
191-
{
192-
processRunning = !process.HasExited;
193-
}
194-
catch
195-
{
196-
processRunning = false;
197-
}
198-
}
199-
200208
try
201209
{
210+
processRunning = processRunning && !HasExited(process);
202211
if (processRunning)
203212
{
204213
// Wait for the process to exit.
@@ -214,6 +223,19 @@ internal static void DisposeProcess(
214223
}
215224
}
216225

226+
/// <summary>Gets whether <paramref name="process"/> has exited.</summary>
227+
internal static bool HasExited(Process process)
228+
{
229+
try
230+
{
231+
return process.HasExited;
232+
}
233+
catch
234+
{
235+
return true;
236+
}
237+
}
238+
217239
[LoggerMessage(Level = LogLevel.Information, Message = "{EndpointName} connecting.")]
218240
private static partial void LogTransportConnecting(ILogger logger, string endpointName);
219241

src/ModelContextProtocol/Protocol/Transport/StreamClientSessionTransport.cs

Lines changed: 11 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -53,15 +53,15 @@ public StreamClientSessionTransport(
5353
_readTask = readTask.Unwrap();
5454
readTask.Start();
5555

56-
SetConnected(true);
56+
SetConnected();
5757
}
5858

5959
/// <inheritdoc/>
6060
public override async Task SendMessageAsync(JsonRpcMessage message, CancellationToken cancellationToken = default)
6161
{
6262
if (!IsConnected)
6363
{
64-
throw new InvalidOperationException("Transport is not connected");
64+
throw new InvalidOperationException("Transport is not connected.");
6565
}
6666

6767
string id = "(no id)";
@@ -82,31 +82,22 @@ public override async Task SendMessageAsync(JsonRpcMessage message, Cancellation
8282
catch (Exception ex)
8383
{
8484
LogTransportSendFailed(Name, id, ex);
85-
throw new InvalidOperationException("Failed to send message", ex);
85+
throw new IOException("Failed to send message", ex);
8686
}
8787
}
8888

8989
/// <inheritdoc/>
90-
/// <summary>
91-
/// Asynchronously releases all resources used by the stream client session transport.
92-
/// </summary>
93-
/// <returns>A task that represents the asynchronous dispose operation.</returns>
94-
/// <remarks>
95-
/// This method cancels ongoing operations and waits for the read task to complete
96-
/// before marking the transport as disconnected. It calls <see cref="CleanupAsync"/>
97-
/// to perform the actual cleanup work.
98-
/// After disposal, the transport can no longer be used to send or receive messages.
99-
/// </remarks>
100-
public override ValueTask DisposeAsync() =>
101-
CleanupAsync(CancellationToken.None);
90+
public override ValueTask DisposeAsync() =>
91+
CleanupAsync(cancellationToken: CancellationToken.None);
10292

10393
private async Task ReadMessagesAsync(CancellationToken cancellationToken)
10494
{
95+
Exception? error = null;
10596
try
10697
{
10798
LogTransportEnteringReadMessagesLoop(Name);
10899

109-
while (!cancellationToken.IsCancellationRequested)
100+
while (true)
110101
{
111102
if (await _serverOutput.ReadLineAsync(cancellationToken).ConfigureAwait(false) is not string line)
112103
{
@@ -130,12 +121,13 @@ private async Task ReadMessagesAsync(CancellationToken cancellationToken)
130121
}
131122
catch (Exception ex)
132123
{
124+
error = ex;
133125
LogTransportReadMessagesFailed(Name, ex);
134126
}
135127
finally
136128
{
137129
_readTask = null;
138-
await CleanupAsync(cancellationToken).ConfigureAwait(false);
130+
await CleanupAsync(error, cancellationToken).ConfigureAwait(false);
139131
}
140132
}
141133

@@ -166,7 +158,7 @@ private async Task ProcessMessageAsync(string line, CancellationToken cancellati
166158
}
167159
}
168160

169-
protected virtual async ValueTask CleanupAsync(CancellationToken cancellationToken)
161+
protected virtual async ValueTask CleanupAsync(Exception? error = null, CancellationToken cancellationToken = default)
170162
{
171163
LogTransportShuttingDown(Name);
172164

@@ -191,7 +183,7 @@ protected virtual async ValueTask CleanupAsync(CancellationToken cancellationTok
191183
}
192184
}
193185

194-
SetConnected(false);
186+
SetDisconnected(error);
195187
LogTransportShutDown(Name);
196188
}
197189
}

0 commit comments

Comments
 (0)