Skip to content

Commit 201f05d

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 8d4f51d commit 201f05d

File tree

10 files changed

+202
-126
lines changed

10 files changed

+202
-126
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

src/ModelContextProtocol/Protocol/Transport/StdioClientSessionTransport.cs

Lines changed: 61 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -9,54 +9,51 @@ internal sealed class StdioClientSessionTransport : StreamClientSessionTransport
99
{
1010
private readonly StdioClientTransportOptions _options;
1111
private readonly Process _process;
12+
private readonly Queue<string> _stderrRollingLog;
13+
private int _cleanedUp = 0;
1214

13-
public StdioClientSessionTransport(StdioClientTransportOptions options, Process process, string endpointName, ILoggerFactory? loggerFactory)
15+
public StdioClientSessionTransport(StdioClientTransportOptions options, Process process, string endpointName, Queue<string> stderrRollingLog, ILoggerFactory? loggerFactory)
1416
: base(process.StandardInput, process.StandardOutput, endpointName, loggerFactory)
1517
{
1618
_process = process;
1719
_options = options;
20+
_stderrRollingLog = stderrRollingLog;
1821
}
1922

2023
/// <inheritdoc/>
21-
/// <remarks>
22-
/// <para>
23-
/// For stdio-based transports, this implementation first verifies that the underlying process
24-
/// is still running before attempting to send the message. If the process has exited or cannot
25-
/// be accessed, a <see cref="InvalidOperationException"/> is thrown with details about the failure.
26-
/// </para>
27-
/// <para>
28-
/// After verifying the process state, this method delegates to the base class implementation
29-
/// to handle the actual message serialization and transmission to the process's standard input stream.
30-
/// </para>
31-
/// </remarks>
32-
/// <exception cref="InvalidOperationException">
33-
/// Thrown when the underlying process has exited or cannot be accessed.
34-
/// </exception>
3524
public override async Task SendMessageAsync(JsonRpcMessage message, CancellationToken cancellationToken = default)
3625
{
37-
Exception? processException = null;
38-
bool hasExited = false;
3926
try
4027
{
41-
hasExited = _process.HasExited;
28+
await base.SendMessageAsync(message, cancellationToken);
4229
}
43-
catch (Exception e)
30+
catch (IOException)
4431
{
45-
processException = e;
46-
hasExited = true;
47-
}
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+
}
4838

49-
if (hasExited)
50-
{
51-
throw new InvalidOperationException("Transport is not connected", processException);
39+
throw;
5240
}
53-
54-
await base.SendMessageAsync(message, cancellationToken).ConfigureAwait(false);
5541
}
5642

5743
/// <inheritdoc/>
58-
protected override ValueTask CleanupAsync(CancellationToken cancellationToken)
44+
protected override async ValueTask CleanupAsync(Exception? error = null, CancellationToken cancellationToken = default)
5945
{
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.
6057
try
6158
{
6259
StdioClientTransport.DisposeProcess(_process, processRunning: true, _options.ShutdownTimeout, Name);
@@ -66,6 +63,41 @@ protected override ValueTask CleanupAsync(CancellationToken cancellationToken)
6663
LogTransportShutdownFailed(Name, ex);
6764
}
6865

69-
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);
70102
}
71103
}

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)