Skip to content

Commit b965a7e

Browse files
stephentoubCopilot
andauthored
Add client completion notification and details (#1368)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 4af2596 commit b965a7e

22 files changed

+691
-31
lines changed
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
namespace ModelContextProtocol.Client;
2+
3+
/// <summary>
4+
/// Provides details about why an MCP client session completed.
5+
/// </summary>
6+
/// <remarks>
7+
/// <para>
8+
/// Transport implementations may return derived types with additional strongly-typed
9+
/// information, such as <see cref="StdioClientCompletionDetails"/>.
10+
/// </para>
11+
/// </remarks>
12+
public class ClientCompletionDetails
13+
{
14+
/// <summary>
15+
/// Gets the exception that caused the session to close, if any.
16+
/// </summary>
17+
/// <remarks>
18+
/// This is <see langword="null"/> for graceful closure.
19+
/// </remarks>
20+
public Exception? Exception { get; set; }
21+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
using System.Net;
2+
3+
namespace ModelContextProtocol.Client;
4+
5+
/// <summary>
6+
/// Provides details about the completion of an HTTP-based MCP client session,
7+
/// including sessions using the legacy SSE transport or the Streamable HTTP transport.
8+
/// </summary>
9+
public sealed class HttpClientCompletionDetails : ClientCompletionDetails
10+
{
11+
/// <summary>
12+
/// Gets the HTTP status code that caused the session to close, or <see langword="null"/> if unavailable.
13+
/// </summary>
14+
public HttpStatusCode? HttpStatusCode { get; set; }
15+
}

src/ModelContextProtocol.Core/Client/McpClient.cs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,4 +53,21 @@ protected McpClient()
5353
/// </para>
5454
/// </remarks>
5555
public abstract string? ServerInstructions { get; }
56+
57+
/// <summary>
58+
/// Gets a <see cref="Task{TResult}"/> that completes when the client session has completed.
59+
/// </summary>
60+
/// <remarks>
61+
/// <para>
62+
/// The task always completes successfully. The result provides details about why the session
63+
/// completed. Transport implementations may return derived types with additional strongly-typed
64+
/// information, such as <see cref="StdioClientCompletionDetails"/>.
65+
/// </para>
66+
/// <para>
67+
/// For graceful closure (e.g., explicit disposal), <see cref="ClientCompletionDetails.Exception"/>
68+
/// will be <see langword="null"/>. For unexpected closure (e.g., process crash, network failure),
69+
/// it may contain an exception that caused or that represents the failure.
70+
/// </para>
71+
/// </remarks>
72+
public abstract Task<ClientCompletionDetails> Completion { get; }
5673
}

src/ModelContextProtocol.Core/Client/McpClientImpl.cs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -521,6 +521,9 @@ private void RegisterTaskHandlers(RequestHandlers requestHandlers, IMcpTaskStore
521521
/// <inheritdoc/>
522522
public override string? ServerInstructions => _serverInstructions;
523523

524+
/// <inheritdoc/>
525+
public override Task<ClientCompletionDetails> Completion => _sessionHandler.CompletionTask;
526+
524527
/// <summary>
525528
/// Asynchronously connects to an MCP server, establishes the transport connection, and completes the initialization handshake.
526529
/// </summary>
@@ -655,6 +658,14 @@ public override async ValueTask DisposeAsync()
655658
_taskCancellationTokenProvider?.Dispose();
656659
await _sessionHandler.DisposeAsync().ConfigureAwait(false);
657660
await _transport.DisposeAsync().ConfigureAwait(false);
661+
662+
// After disposal, the channel writer is complete but ProcessMessagesCoreAsync
663+
// may have been cancelled with unread items still buffered. ChannelReader.Completion
664+
// only resolves once all items are consumed, so drain remaining items.
665+
while (_transport.MessageReader.TryRead(out var _));
666+
667+
// Then ensure all work has quiesced.
668+
await Completion.ConfigureAwait(false);
658669
}
659670

660671
[LoggerMessage(Level = LogLevel.Information, Message = "{EndpointName} client received server '{ServerInfo}' capabilities: '{Capabilities}'.")]

src/ModelContextProtocol.Core/Client/SseClientSessionTransport.cs

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
using Microsoft.Extensions.Logging.Abstractions;
33
using ModelContextProtocol.Protocol;
44
using System.Diagnostics;
5+
using System.Net;
56
using System.Net.Http.Headers;
67
using System.Net.ServerSentEvents;
78
using System.Text.Json;
@@ -124,7 +125,7 @@ private async Task CloseAsync()
124125
}
125126
finally
126127
{
127-
SetDisconnected();
128+
SetDisconnected(new TransportClosedException(new HttpClientCompletionDetails()));
128129
}
129130
}
130131

@@ -143,6 +144,7 @@ public override async ValueTask DisposeAsync()
143144

144145
private async Task ReceiveMessagesAsync(CancellationToken cancellationToken)
145146
{
147+
HttpStatusCode? failureStatusCode = null;
146148
try
147149
{
148150
using var request = new HttpRequestMessage(HttpMethod.Get, _sseEndpoint);
@@ -151,6 +153,11 @@ private async Task ReceiveMessagesAsync(CancellationToken cancellationToken)
151153

152154
using var response = await _httpClient.SendAsync(request, message: null, cancellationToken).ConfigureAwait(false);
153155

156+
if (!response.IsSuccessStatusCode)
157+
{
158+
failureStatusCode = response.StatusCode;
159+
}
160+
154161
await response.EnsureSuccessStatusCodeWithResponseBodyAsync(cancellationToken).ConfigureAwait(false);
155162

156163
using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
@@ -179,14 +186,20 @@ private async Task ReceiveMessagesAsync(CancellationToken cancellationToken)
179186
}
180187
else
181188
{
189+
SetDisconnected(new TransportClosedException(new HttpClientCompletionDetails
190+
{
191+
HttpStatusCode = failureStatusCode,
192+
Exception = ex,
193+
}));
194+
182195
LogTransportReadMessagesFailed(Name, ex);
183196
_connectionEstablished.TrySetException(ex);
184197
throw;
185198
}
186199
}
187200
finally
188201
{
189-
SetDisconnected();
202+
SetDisconnected(new TransportClosedException(new HttpClientCompletionDetails()));
190203
}
191204
}
192205

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
namespace ModelContextProtocol.Client;
2+
3+
/// <summary>
4+
/// Provides details about the completion of a stdio-based MCP client session.
5+
/// </summary>
6+
public sealed class StdioClientCompletionDetails : ClientCompletionDetails
7+
{
8+
/// <summary>
9+
/// Gets the process ID of the server process, or <see langword="null"/> if unavailable.
10+
/// </summary>
11+
public int? ProcessId { get; set; }
12+
13+
/// <summary>
14+
/// Gets the exit code of the server process, or <see langword="null"/> if unavailable.
15+
/// </summary>
16+
public int? ExitCode { get; set; }
17+
18+
/// <summary>
19+
/// Gets the last lines of the server process's standard error output, or <see langword="null"/> if unavailable.
20+
/// </summary>
21+
public IReadOnlyList<string>? StandardErrorTail { get; set; }
22+
}

src/ModelContextProtocol.Core/Client/StdioClientSessionTransport.cs

Lines changed: 54 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,22 @@
55
namespace ModelContextProtocol.Client;
66

77
/// <summary>Provides the client side of a stdio-based session transport.</summary>
8-
internal sealed class StdioClientSessionTransport(
9-
StdioClientTransportOptions options, Process process, string endpointName, Queue<string> stderrRollingLog, ILoggerFactory? loggerFactory) :
10-
StreamClientSessionTransport(process.StandardInput.BaseStream, process.StandardOutput.BaseStream, encoding: null, endpointName, loggerFactory)
8+
internal sealed class StdioClientSessionTransport : StreamClientSessionTransport
119
{
12-
private readonly StdioClientTransportOptions _options = options;
13-
private readonly Process _process = process;
14-
private readonly Queue<string> _stderrRollingLog = stderrRollingLog;
10+
private readonly StdioClientTransportOptions _options;
11+
private readonly Process _process;
12+
private readonly Queue<string> _stderrRollingLog;
1513
private int _cleanedUp = 0;
14+
private readonly int? _processId;
15+
16+
public StdioClientSessionTransport(StdioClientTransportOptions options, Process process, string endpointName, Queue<string> stderrRollingLog, ILoggerFactory? loggerFactory) :
17+
base(process.StandardInput.BaseStream, process.StandardOutput.BaseStream, encoding: null, endpointName, loggerFactory)
18+
{
19+
_options = options;
20+
_process = process;
21+
_stderrRollingLog = stderrRollingLog;
22+
try { _processId = process.Id; } catch { }
23+
}
1624

1725
/// <inheritdoc/>
1826
public override async Task SendMessageAsync(JsonRpcMessage message, CancellationToken cancellationToken = default)
@@ -47,17 +55,26 @@ protected override async ValueTask CleanupAsync(Exception? error = null, Cancell
4755
// so create an exception with details about that.
4856
error ??= await GetUnexpectedExitExceptionAsync(cancellationToken).ConfigureAwait(false);
4957

50-
// Now terminate the server process.
58+
// Terminate the server process (or confirm it already exited), then build
59+
// and publish strongly-typed completion details while the process handle
60+
// is still valid so we can read the exit code.
5161
try
5262
{
53-
StdioClientTransport.DisposeProcess(_process, processRunning: true, shutdownTimeout: _options.ShutdownTimeout);
63+
StdioClientTransport.DisposeProcess(
64+
_process,
65+
processRunning: true,
66+
_options.ShutdownTimeout,
67+
beforeDispose: () => SetDisconnected(new TransportClosedException(BuildCompletionDetails(error))));
5468
}
5569
catch (Exception ex)
5670
{
5771
LogTransportShutdownFailed(Name, ex);
72+
SetDisconnected(new TransportClosedException(BuildCompletionDetails(error)));
5873
}
5974

60-
// And handle cleanup in the base type.
75+
// And handle cleanup in the base type. SetDisconnected has already been
76+
// called above, so the base call is a no-op for disconnect state but
77+
// still performs other cleanup (cancelling the read task, etc.).
6178
await base.CleanupAsync(error, cancellationToken).ConfigureAwait(false);
6279
}
6380

@@ -104,4 +121,32 @@ protected override async ValueTask CleanupAsync(Exception? error = null, Cancell
104121

105122
return new IOException(errorMessage);
106123
}
124+
125+
private StdioClientCompletionDetails BuildCompletionDetails(Exception? error)
126+
{
127+
StdioClientCompletionDetails details = new()
128+
{
129+
Exception = error,
130+
ProcessId = _processId,
131+
};
132+
133+
try
134+
{
135+
if (StdioClientTransport.HasExited(_process))
136+
{
137+
details.ExitCode = _process.ExitCode;
138+
}
139+
}
140+
catch { }
141+
142+
lock (_stderrRollingLog)
143+
{
144+
if (_stderrRollingLog.Count > 0)
145+
{
146+
details.StandardErrorTail = _stderrRollingLog.ToArray();
147+
}
148+
}
149+
150+
return details;
151+
}
107152
}

src/ModelContextProtocol.Core/Client/StdioClientTransport.cs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -213,7 +213,7 @@ public async Task<ITransport> ConnectAsync(CancellationToken cancellationToken =
213213
}
214214

215215
internal static void DisposeProcess(
216-
Process? process, bool processRunning, TimeSpan shutdownTimeout)
216+
Process? process, bool processRunning, TimeSpan shutdownTimeout, Action? beforeDispose = null)
217217
{
218218
if (process is not null)
219219
{
@@ -239,6 +239,10 @@ internal static void DisposeProcess(
239239
{
240240
process.WaitForExit();
241241
}
242+
243+
// Invoke the callback while the process handle is still valid,
244+
// e.g. to read ExitCode before Dispose() invalidates it.
245+
beforeDispose?.Invoke();
242246
}
243247
finally
244248
{

src/ModelContextProtocol.Core/Client/StreamableHttpClientSessionTransport.cs

Lines changed: 40 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,12 @@ internal sealed partial class StreamableHttpClientSessionTransport : TransportBa
2020

2121
private readonly McpHttpClient _httpClient;
2222
private readonly HttpClientTransportOptions _options;
23-
private readonly CancellationTokenSource _connectionCts;
23+
private readonly CancellationTokenSource _connectionCts = new();
2424
private readonly ILogger _logger;
2525

2626
private string? _negotiatedProtocolVersion;
2727
private Task? _getReceiveTask;
28+
private volatile TransportClosedException? _disconnectError;
2829

2930
private readonly SemaphoreSlim _disposeLock = new(1, 1);
3031
private bool _disposed;
@@ -42,7 +43,6 @@ public StreamableHttpClientSessionTransport(
4243

4344
_options = transportOptions;
4445
_httpClient = httpClient;
45-
_connectionCts = new CancellationTokenSource();
4646
_logger = (ILogger?)loggerFactory?.CreateLogger<HttpClientTransport>() ?? NullLogger.Instance;
4747

4848
// We connect with the initialization request with the MCP transport. This means that any errors won't be observed
@@ -96,6 +96,13 @@ internal async Task<HttpResponseMessage> SendHttpRequestAsync(JsonRpcMessage mes
9696
// We'll let the caller decide whether to throw or fall back given an unsuccessful response.
9797
if (!response.IsSuccessStatusCode)
9898
{
99+
// Per the MCP spec, a 404 response to a request containing an Mcp-Session-Id
100+
// indicates the session has ended. Signal completion so McpClient.Completion resolves.
101+
if (response.StatusCode == HttpStatusCode.NotFound && SessionId is not null)
102+
{
103+
SetSessionExpired();
104+
}
105+
99106
return response;
100107
}
101108

@@ -184,18 +191,16 @@ public override async ValueTask DisposeAsync()
184191
{
185192
LogTransportShutdownFailed(Name, ex);
186193
}
187-
finally
188-
{
189-
_connectionCts.Dispose();
190-
}
191194
}
192195
finally
193196
{
194197
// If we're auto-detecting the transport and failed to connect, leave the message Channel open for the SSE transport.
195198
// This class isn't directly exposed to public callers, so we don't have to worry about changing the _state in this case.
196199
if (_options.TransportMode is not HttpTransportMode.AutoDetect || _getReceiveTask is not null)
197200
{
198-
SetDisconnected();
201+
// _disconnectError is set when the server returns 404 indicating session expiry.
202+
// When null, this is a graceful client-initiated closure (no error).
203+
SetDisconnected(_disconnectError ?? new TransportClosedException(new HttpClientCompletionDetails()));
199204
}
200205
}
201206
}
@@ -204,8 +209,8 @@ private async Task ReceiveUnsolicitedMessagesAsync()
204209
{
205210
var state = new SseStreamState();
206211

207-
// Continuously receive unsolicited messages until canceled
208-
while (!_connectionCts.Token.IsCancellationRequested)
212+
// Continuously receive unsolicited messages until canceled or disconnected
213+
while (!_connectionCts.Token.IsCancellationRequested && IsConnected)
209214
{
210215
await SendGetSseRequestWithRetriesAsync(
211216
relatedRpcRequest: null,
@@ -285,6 +290,13 @@ await SendGetSseRequestWithRetriesAsync(
285290

286291
if (!response.IsSuccessStatusCode)
287292
{
293+
// Per the MCP spec, a 404 response to a request containing an Mcp-Session-Id
294+
// indicates the session has ended. Signal completion so McpClient.Completion resolves.
295+
if (response.StatusCode == HttpStatusCode.NotFound && SessionId is not null)
296+
{
297+
SetSessionExpired();
298+
}
299+
288300
// If the server could be reached but returned a non-success status code,
289301
// retrying likely won't change that.
290302
return null;
@@ -474,4 +486,23 @@ private static TimeSpan ElapsedSince(long stopwatchTimestamp)
474486
return TimeSpan.FromSeconds((double)(Stopwatch.GetTimestamp() - stopwatchTimestamp) / Stopwatch.Frequency);
475487
#endif
476488
}
489+
490+
private void SetSessionExpired()
491+
{
492+
// Store the error before canceling so DisposeAsync can use it if it races us, especially
493+
// after the call to Cancel below, to invoke SetDisconnected.
494+
_disconnectError = new TransportClosedException(new HttpClientCompletionDetails
495+
{
496+
HttpStatusCode = HttpStatusCode.NotFound,
497+
Exception = new McpException(
498+
"The server returned HTTP 404 for a request with an Mcp-Session-Id, indicating the session has expired. " +
499+
"To continue, create a new client session or call ResumeSessionAsync with a new connection."),
500+
});
501+
502+
// Cancel to unblock any in-flight operations (e.g., SSE stream reads in
503+
// SendGetSseRequestWithRetriesAsync) that are waiting on _connectionCts.Token.
504+
_connectionCts.Cancel();
505+
506+
SetDisconnected(_disconnectError);
507+
}
477508
}

0 commit comments

Comments
 (0)