Add client completion notification and details (#1368)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
parent
4af2596019
commit
b965a7eca6
22 changed files with 691 additions and 31 deletions
|
|
@ -0,0 +1,21 @@
|
|||
namespace ModelContextProtocol.Client;
|
||||
|
||||
/// <summary>
|
||||
/// Provides details about why an MCP client session completed.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Transport implementations may return derived types with additional strongly-typed
|
||||
/// information, such as <see cref="StdioClientCompletionDetails"/>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public class ClientCompletionDetails
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the exception that caused the session to close, if any.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This is <see langword="null"/> for graceful closure.
|
||||
/// </remarks>
|
||||
public Exception? Exception { get; set; }
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
using System.Net;
|
||||
|
||||
namespace ModelContextProtocol.Client;
|
||||
|
||||
/// <summary>
|
||||
/// Provides details about the completion of an HTTP-based MCP client session,
|
||||
/// including sessions using the legacy SSE transport or the Streamable HTTP transport.
|
||||
/// </summary>
|
||||
public sealed class HttpClientCompletionDetails : ClientCompletionDetails
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the HTTP status code that caused the session to close, or <see langword="null"/> if unavailable.
|
||||
/// </summary>
|
||||
public HttpStatusCode? HttpStatusCode { get; set; }
|
||||
}
|
||||
|
|
@ -53,4 +53,21 @@ public abstract partial class McpClient : McpSession
|
|||
/// </para>
|
||||
/// </remarks>
|
||||
public abstract string? ServerInstructions { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets a <see cref="Task{TResult}"/> that completes when the client session has completed.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The task always completes successfully. The result provides details about why the session
|
||||
/// completed. Transport implementations may return derived types with additional strongly-typed
|
||||
/// information, such as <see cref="StdioClientCompletionDetails"/>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// For graceful closure (e.g., explicit disposal), <see cref="ClientCompletionDetails.Exception"/>
|
||||
/// will be <see langword="null"/>. For unexpected closure (e.g., process crash, network failure),
|
||||
/// it may contain an exception that caused or that represents the failure.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public abstract Task<ClientCompletionDetails> Completion { get; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -521,6 +521,9 @@ internal sealed partial class McpClientImpl : McpClient
|
|||
/// <inheritdoc/>
|
||||
public override string? ServerInstructions => _serverInstructions;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override Task<ClientCompletionDetails> Completion => _sessionHandler.CompletionTask;
|
||||
|
||||
/// <summary>
|
||||
/// Asynchronously connects to an MCP server, establishes the transport connection, and completes the initialization handshake.
|
||||
/// </summary>
|
||||
|
|
@ -655,6 +658,14 @@ internal sealed partial class McpClientImpl : McpClient
|
|||
_taskCancellationTokenProvider?.Dispose();
|
||||
await _sessionHandler.DisposeAsync().ConfigureAwait(false);
|
||||
await _transport.DisposeAsync().ConfigureAwait(false);
|
||||
|
||||
// After disposal, the channel writer is complete but ProcessMessagesCoreAsync
|
||||
// may have been cancelled with unread items still buffered. ChannelReader.Completion
|
||||
// only resolves once all items are consumed, so drain remaining items.
|
||||
while (_transport.MessageReader.TryRead(out var _));
|
||||
|
||||
// Then ensure all work has quiesced.
|
||||
await Completion.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
[LoggerMessage(Level = LogLevel.Information, Message = "{EndpointName} client received server '{ServerInfo}' capabilities: '{Capabilities}'.")]
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ using Microsoft.Extensions.Logging;
|
|||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using ModelContextProtocol.Protocol;
|
||||
using System.Diagnostics;
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.ServerSentEvents;
|
||||
using System.Text.Json;
|
||||
|
|
@ -124,7 +125,7 @@ internal sealed partial class SseClientSessionTransport : TransportBase
|
|||
}
|
||||
finally
|
||||
{
|
||||
SetDisconnected();
|
||||
SetDisconnected(new TransportClosedException(new HttpClientCompletionDetails()));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -143,6 +144,7 @@ internal sealed partial class SseClientSessionTransport : TransportBase
|
|||
|
||||
private async Task ReceiveMessagesAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
HttpStatusCode? failureStatusCode = null;
|
||||
try
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, _sseEndpoint);
|
||||
|
|
@ -151,6 +153,11 @@ internal sealed partial class SseClientSessionTransport : TransportBase
|
|||
|
||||
using var response = await _httpClient.SendAsync(request, message: null, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
failureStatusCode = response.StatusCode;
|
||||
}
|
||||
|
||||
await response.EnsureSuccessStatusCodeWithResponseBodyAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
|
@ -179,6 +186,12 @@ internal sealed partial class SseClientSessionTransport : TransportBase
|
|||
}
|
||||
else
|
||||
{
|
||||
SetDisconnected(new TransportClosedException(new HttpClientCompletionDetails
|
||||
{
|
||||
HttpStatusCode = failureStatusCode,
|
||||
Exception = ex,
|
||||
}));
|
||||
|
||||
LogTransportReadMessagesFailed(Name, ex);
|
||||
_connectionEstablished.TrySetException(ex);
|
||||
throw;
|
||||
|
|
@ -186,7 +199,7 @@ internal sealed partial class SseClientSessionTransport : TransportBase
|
|||
}
|
||||
finally
|
||||
{
|
||||
SetDisconnected();
|
||||
SetDisconnected(new TransportClosedException(new HttpClientCompletionDetails()));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,22 @@
|
|||
namespace ModelContextProtocol.Client;
|
||||
|
||||
/// <summary>
|
||||
/// Provides details about the completion of a stdio-based MCP client session.
|
||||
/// </summary>
|
||||
public sealed class StdioClientCompletionDetails : ClientCompletionDetails
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the process ID of the server process, or <see langword="null"/> if unavailable.
|
||||
/// </summary>
|
||||
public int? ProcessId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the exit code of the server process, or <see langword="null"/> if unavailable.
|
||||
/// </summary>
|
||||
public int? ExitCode { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the last lines of the server process's standard error output, or <see langword="null"/> if unavailable.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string>? StandardErrorTail { get; set; }
|
||||
}
|
||||
|
|
@ -5,14 +5,22 @@ using System.Diagnostics;
|
|||
namespace ModelContextProtocol.Client;
|
||||
|
||||
/// <summary>Provides the client side of a stdio-based session transport.</summary>
|
||||
internal sealed class StdioClientSessionTransport(
|
||||
StdioClientTransportOptions options, Process process, string endpointName, Queue<string> stderrRollingLog, ILoggerFactory? loggerFactory) :
|
||||
StreamClientSessionTransport(process.StandardInput.BaseStream, process.StandardOutput.BaseStream, encoding: null, endpointName, loggerFactory)
|
||||
internal sealed class StdioClientSessionTransport : StreamClientSessionTransport
|
||||
{
|
||||
private readonly StdioClientTransportOptions _options = options;
|
||||
private readonly Process _process = process;
|
||||
private readonly Queue<string> _stderrRollingLog = stderrRollingLog;
|
||||
private readonly StdioClientTransportOptions _options;
|
||||
private readonly Process _process;
|
||||
private readonly Queue<string> _stderrRollingLog;
|
||||
private int _cleanedUp = 0;
|
||||
private readonly int? _processId;
|
||||
|
||||
public StdioClientSessionTransport(StdioClientTransportOptions options, Process process, string endpointName, Queue<string> stderrRollingLog, ILoggerFactory? loggerFactory) :
|
||||
base(process.StandardInput.BaseStream, process.StandardOutput.BaseStream, encoding: null, endpointName, loggerFactory)
|
||||
{
|
||||
_options = options;
|
||||
_process = process;
|
||||
_stderrRollingLog = stderrRollingLog;
|
||||
try { _processId = process.Id; } catch { }
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override async Task SendMessageAsync(JsonRpcMessage message, CancellationToken cancellationToken = default)
|
||||
|
|
@ -47,17 +55,26 @@ internal sealed class StdioClientSessionTransport(
|
|||
// so create an exception with details about that.
|
||||
error ??= await GetUnexpectedExitExceptionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Now terminate the server process.
|
||||
// Terminate the server process (or confirm it already exited), then build
|
||||
// and publish strongly-typed completion details while the process handle
|
||||
// is still valid so we can read the exit code.
|
||||
try
|
||||
{
|
||||
StdioClientTransport.DisposeProcess(_process, processRunning: true, shutdownTimeout: _options.ShutdownTimeout);
|
||||
StdioClientTransport.DisposeProcess(
|
||||
_process,
|
||||
processRunning: true,
|
||||
_options.ShutdownTimeout,
|
||||
beforeDispose: () => SetDisconnected(new TransportClosedException(BuildCompletionDetails(error))));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogTransportShutdownFailed(Name, ex);
|
||||
SetDisconnected(new TransportClosedException(BuildCompletionDetails(error)));
|
||||
}
|
||||
|
||||
// And handle cleanup in the base type.
|
||||
// And handle cleanup in the base type. SetDisconnected has already been
|
||||
// called above, so the base call is a no-op for disconnect state but
|
||||
// still performs other cleanup (cancelling the read task, etc.).
|
||||
await base.CleanupAsync(error, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
|
|
@ -104,4 +121,32 @@ internal sealed class StdioClientSessionTransport(
|
|||
|
||||
return new IOException(errorMessage);
|
||||
}
|
||||
|
||||
private StdioClientCompletionDetails BuildCompletionDetails(Exception? error)
|
||||
{
|
||||
StdioClientCompletionDetails details = new()
|
||||
{
|
||||
Exception = error,
|
||||
ProcessId = _processId,
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
if (StdioClientTransport.HasExited(_process))
|
||||
{
|
||||
details.ExitCode = _process.ExitCode;
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
|
||||
lock (_stderrRollingLog)
|
||||
{
|
||||
if (_stderrRollingLog.Count > 0)
|
||||
{
|
||||
details.StandardErrorTail = _stderrRollingLog.ToArray();
|
||||
}
|
||||
}
|
||||
|
||||
return details;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -213,7 +213,7 @@ public sealed partial class StdioClientTransport : IClientTransport
|
|||
}
|
||||
|
||||
internal static void DisposeProcess(
|
||||
Process? process, bool processRunning, TimeSpan shutdownTimeout)
|
||||
Process? process, bool processRunning, TimeSpan shutdownTimeout, Action? beforeDispose = null)
|
||||
{
|
||||
if (process is not null)
|
||||
{
|
||||
|
|
@ -239,6 +239,10 @@ public sealed partial class StdioClientTransport : IClientTransport
|
|||
{
|
||||
process.WaitForExit();
|
||||
}
|
||||
|
||||
// Invoke the callback while the process handle is still valid,
|
||||
// e.g. to read ExitCode before Dispose() invalidates it.
|
||||
beforeDispose?.Invoke();
|
||||
}
|
||||
finally
|
||||
{
|
||||
|
|
|
|||
|
|
@ -20,11 +20,12 @@ internal sealed partial class StreamableHttpClientSessionTransport : TransportBa
|
|||
|
||||
private readonly McpHttpClient _httpClient;
|
||||
private readonly HttpClientTransportOptions _options;
|
||||
private readonly CancellationTokenSource _connectionCts;
|
||||
private readonly CancellationTokenSource _connectionCts = new();
|
||||
private readonly ILogger _logger;
|
||||
|
||||
private string? _negotiatedProtocolVersion;
|
||||
private Task? _getReceiveTask;
|
||||
private volatile TransportClosedException? _disconnectError;
|
||||
|
||||
private readonly SemaphoreSlim _disposeLock = new(1, 1);
|
||||
private bool _disposed;
|
||||
|
|
@ -42,7 +43,6 @@ internal sealed partial class StreamableHttpClientSessionTransport : TransportBa
|
|||
|
||||
_options = transportOptions;
|
||||
_httpClient = httpClient;
|
||||
_connectionCts = new CancellationTokenSource();
|
||||
_logger = (ILogger?)loggerFactory?.CreateLogger<HttpClientTransport>() ?? NullLogger.Instance;
|
||||
|
||||
// We connect with the initialization request with the MCP transport. This means that any errors won't be observed
|
||||
|
|
@ -96,6 +96,13 @@ internal sealed partial class StreamableHttpClientSessionTransport : TransportBa
|
|||
// We'll let the caller decide whether to throw or fall back given an unsuccessful response.
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
// Per the MCP spec, a 404 response to a request containing an Mcp-Session-Id
|
||||
// indicates the session has ended. Signal completion so McpClient.Completion resolves.
|
||||
if (response.StatusCode == HttpStatusCode.NotFound && SessionId is not null)
|
||||
{
|
||||
SetSessionExpired();
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
|
|
@ -184,10 +191,6 @@ internal sealed partial class StreamableHttpClientSessionTransport : TransportBa
|
|||
{
|
||||
LogTransportShutdownFailed(Name, ex);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_connectionCts.Dispose();
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
|
|
@ -195,7 +198,9 @@ internal sealed partial class StreamableHttpClientSessionTransport : TransportBa
|
|||
// This class isn't directly exposed to public callers, so we don't have to worry about changing the _state in this case.
|
||||
if (_options.TransportMode is not HttpTransportMode.AutoDetect || _getReceiveTask is not null)
|
||||
{
|
||||
SetDisconnected();
|
||||
// _disconnectError is set when the server returns 404 indicating session expiry.
|
||||
// When null, this is a graceful client-initiated closure (no error).
|
||||
SetDisconnected(_disconnectError ?? new TransportClosedException(new HttpClientCompletionDetails()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -204,8 +209,8 @@ internal sealed partial class StreamableHttpClientSessionTransport : TransportBa
|
|||
{
|
||||
var state = new SseStreamState();
|
||||
|
||||
// Continuously receive unsolicited messages until canceled
|
||||
while (!_connectionCts.Token.IsCancellationRequested)
|
||||
// Continuously receive unsolicited messages until canceled or disconnected
|
||||
while (!_connectionCts.Token.IsCancellationRequested && IsConnected)
|
||||
{
|
||||
await SendGetSseRequestWithRetriesAsync(
|
||||
relatedRpcRequest: null,
|
||||
|
|
@ -285,6 +290,13 @@ internal sealed partial class StreamableHttpClientSessionTransport : TransportBa
|
|||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
// Per the MCP spec, a 404 response to a request containing an Mcp-Session-Id
|
||||
// indicates the session has ended. Signal completion so McpClient.Completion resolves.
|
||||
if (response.StatusCode == HttpStatusCode.NotFound && SessionId is not null)
|
||||
{
|
||||
SetSessionExpired();
|
||||
}
|
||||
|
||||
// If the server could be reached but returned a non-success status code,
|
||||
// retrying likely won't change that.
|
||||
return null;
|
||||
|
|
@ -474,4 +486,23 @@ internal sealed partial class StreamableHttpClientSessionTransport : TransportBa
|
|||
return TimeSpan.FromSeconds((double)(Stopwatch.GetTimestamp() - stopwatchTimestamp) / Stopwatch.Frequency);
|
||||
#endif
|
||||
}
|
||||
|
||||
private void SetSessionExpired()
|
||||
{
|
||||
// Store the error before canceling so DisposeAsync can use it if it races us, especially
|
||||
// after the call to Cancel below, to invoke SetDisconnected.
|
||||
_disconnectError = new TransportClosedException(new HttpClientCompletionDetails
|
||||
{
|
||||
HttpStatusCode = HttpStatusCode.NotFound,
|
||||
Exception = new McpException(
|
||||
"The server returned HTTP 404 for a request with an Mcp-Session-Id, indicating the session has expired. " +
|
||||
"To continue, create a new client session or call ResumeSessionAsync with a new connection."),
|
||||
});
|
||||
|
||||
// Cancel to unblock any in-flight operations (e.g., SSE stream reads in
|
||||
// SendGetSseRequestWithRetriesAsync) that are waiting on _connectionCts.Token.
|
||||
_connectionCts.Cancel();
|
||||
|
||||
SetDisconnected(_disconnectError);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,19 @@
|
|||
using ModelContextProtocol.Protocol;
|
||||
using System.Threading.Channels;
|
||||
|
||||
namespace ModelContextProtocol.Client;
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="IOException"/> used to smuggle <see cref="ClientCompletionDetails"/> through
|
||||
/// the <see cref="ChannelWriter{T}.TryComplete(Exception?)"/> mechanism.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This could be made public in the future to allow custom <see cref="ITransport"/>
|
||||
/// implementations to provide their own <see cref="ClientCompletionDetails"/>-derived types
|
||||
/// by completing their channel with this exception.
|
||||
/// </remarks>
|
||||
internal sealed class TransportClosedException(ClientCompletionDetails details) :
|
||||
IOException(details.Exception?.Message, details.Exception)
|
||||
{
|
||||
public ClientCompletionDetails Details { get; } = details;
|
||||
}
|
||||
60
src/ModelContextProtocol.Core/CompatibilitySuppressions.xml
Normal file
60
src/ModelContextProtocol.Core/CompatibilitySuppressions.xml
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- https://learn.microsoft.com/dotnet/fundamentals/package-validation/diagnostic-ids -->
|
||||
<Suppressions xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema">
|
||||
<Suppression>
|
||||
<DiagnosticId>CP0005</DiagnosticId>
|
||||
<Target>M:ModelContextProtocol.Client.McpClient.get_Completion</Target>
|
||||
<Left>lib/net10.0/ModelContextProtocol.Core.dll</Left>
|
||||
<Right>lib/net10.0/ModelContextProtocol.Core.dll</Right>
|
||||
<IsBaselineSuppression>true</IsBaselineSuppression>
|
||||
</Suppression>
|
||||
<Suppression>
|
||||
<DiagnosticId>CP0005</DiagnosticId>
|
||||
<Target>P:ModelContextProtocol.Client.McpClient.Completion</Target>
|
||||
<Left>lib/net10.0/ModelContextProtocol.Core.dll</Left>
|
||||
<Right>lib/net10.0/ModelContextProtocol.Core.dll</Right>
|
||||
<IsBaselineSuppression>true</IsBaselineSuppression>
|
||||
</Suppression>
|
||||
<Suppression>
|
||||
<DiagnosticId>CP0005</DiagnosticId>
|
||||
<Target>M:ModelContextProtocol.Client.McpClient.get_Completion</Target>
|
||||
<Left>lib/net8.0/ModelContextProtocol.Core.dll</Left>
|
||||
<Right>lib/net8.0/ModelContextProtocol.Core.dll</Right>
|
||||
<IsBaselineSuppression>true</IsBaselineSuppression>
|
||||
</Suppression>
|
||||
<Suppression>
|
||||
<DiagnosticId>CP0005</DiagnosticId>
|
||||
<Target>P:ModelContextProtocol.Client.McpClient.Completion</Target>
|
||||
<Left>lib/net8.0/ModelContextProtocol.Core.dll</Left>
|
||||
<Right>lib/net8.0/ModelContextProtocol.Core.dll</Right>
|
||||
<IsBaselineSuppression>true</IsBaselineSuppression>
|
||||
</Suppression>
|
||||
<Suppression>
|
||||
<DiagnosticId>CP0005</DiagnosticId>
|
||||
<Target>M:ModelContextProtocol.Client.McpClient.get_Completion</Target>
|
||||
<Left>lib/net9.0/ModelContextProtocol.Core.dll</Left>
|
||||
<Right>lib/net9.0/ModelContextProtocol.Core.dll</Right>
|
||||
<IsBaselineSuppression>true</IsBaselineSuppression>
|
||||
</Suppression>
|
||||
<Suppression>
|
||||
<DiagnosticId>CP0005</DiagnosticId>
|
||||
<Target>P:ModelContextProtocol.Client.McpClient.Completion</Target>
|
||||
<Left>lib/net9.0/ModelContextProtocol.Core.dll</Left>
|
||||
<Right>lib/net9.0/ModelContextProtocol.Core.dll</Right>
|
||||
<IsBaselineSuppression>true</IsBaselineSuppression>
|
||||
</Suppression>
|
||||
<Suppression>
|
||||
<DiagnosticId>CP0005</DiagnosticId>
|
||||
<Target>M:ModelContextProtocol.Client.McpClient.get_Completion</Target>
|
||||
<Left>lib/netstandard2.0/ModelContextProtocol.Core.dll</Left>
|
||||
<Right>lib/netstandard2.0/ModelContextProtocol.Core.dll</Right>
|
||||
<IsBaselineSuppression>true</IsBaselineSuppression>
|
||||
</Suppression>
|
||||
<Suppression>
|
||||
<DiagnosticId>CP0005</DiagnosticId>
|
||||
<Target>P:ModelContextProtocol.Client.McpClient.Completion</Target>
|
||||
<Left>lib/netstandard2.0/ModelContextProtocol.Core.dll</Left>
|
||||
<Right>lib/netstandard2.0/ModelContextProtocol.Core.dll</Right>
|
||||
<IsBaselineSuppression>true</IsBaselineSuppression>
|
||||
</Suppression>
|
||||
</Suppressions>
|
||||
|
|
@ -153,6 +153,15 @@ internal sealed partial class McpSessionHandler : IAsyncDisposable
|
|||
/// </summary>
|
||||
public string? NegotiatedProtocolVersion { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets a task that completes when the client session has completed, providing details about the closure.
|
||||
/// Completion details are resolved from the transport's channel completion exception: if a transport
|
||||
/// completes its channel with a <see cref="TransportClosedException"/>, the wrapped
|
||||
/// <see cref="ClientCompletionDetails"/> is unwrapped. Otherwise, a default instance is returned.
|
||||
/// </summary>
|
||||
internal Task<ClientCompletionDetails> CompletionTask =>
|
||||
field ??= GetCompletionDetailsAsync(_transport.MessageReader.Completion);
|
||||
|
||||
/// <summary>
|
||||
/// Starts processing messages from the transport. This method will block until the transport is disconnected.
|
||||
/// This is generally started in a background task or thread from the initialization logic of the derived class.
|
||||
|
|
@ -305,6 +314,28 @@ internal sealed partial class McpSessionHandler : IAsyncDisposable
|
|||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves <see cref="ClientCompletionDetails"/> from the transport's channel completion.
|
||||
/// If the channel was completed with a <see cref="TransportClosedException"/>, the wrapped
|
||||
/// details are returned. Otherwise a default instance is created from the completion state.
|
||||
/// </summary>
|
||||
private static async Task<ClientCompletionDetails> GetCompletionDetailsAsync(Task channelCompletion)
|
||||
{
|
||||
try
|
||||
{
|
||||
await channelCompletion.ConfigureAwait(false);
|
||||
return new ClientCompletionDetails();
|
||||
}
|
||||
catch (TransportClosedException tce)
|
||||
{
|
||||
return tce.Details;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new ClientCompletionDetails { Exception = ex };
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleMessageAsync(JsonRpcMessage message, CancellationToken cancellationToken)
|
||||
{
|
||||
Histogram<double> durationMetric = _isServer ? s_serverOperationDuration : s_clientOperationDuration;
|
||||
|
|
@ -885,9 +916,11 @@ internal sealed partial class McpSessionHandler : IAsyncDisposable
|
|||
{
|
||||
await _messageProcessingTask.ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
catch
|
||||
{
|
||||
// Ignore cancellation
|
||||
// Ignore exceptions from the message processing loop. It may fault with
|
||||
// OperationCanceledException on normal shutdown or TransportClosedException
|
||||
// when the transport's channel completes with an error.
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -90,13 +90,15 @@ public abstract partial class TransportBase : ITransport
|
|||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
|
||||
protected async Task WriteMessageAsync(JsonRpcMessage message, CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
if (!IsConnected)
|
||||
{
|
||||
throw new InvalidOperationException("Transport is not connected.");
|
||||
// Transport disconnected concurrently. Silently drop rather than throw,
|
||||
// to avoid surfacing spurious errors during shutdown races.
|
||||
return;
|
||||
}
|
||||
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
if (_logger.IsEnabled(LogLevel.Debug))
|
||||
{
|
||||
var messageId = (message as JsonRpcMessageWithId)?.Id.ToString() ?? "(no id)";
|
||||
|
|
|
|||
|
|
@ -303,4 +303,19 @@ public abstract class HttpServerIntegrationTests : LoggedTest, IClassFixture<Sse
|
|||
Assert.Equal($"Echo: Hello MCP! {i}", textContent.Text);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Completion_GracefulDisposal_ReturnsCompletionDetails()
|
||||
{
|
||||
var client = await GetClientAsync();
|
||||
Assert.False(client.Completion.IsCompleted);
|
||||
|
||||
await client.DisposeAsync();
|
||||
Assert.True(client.Completion.IsCompleted);
|
||||
|
||||
var details = await client.Completion;
|
||||
var httpDetails = Assert.IsType<HttpClientCompletionDetails>(details);
|
||||
Assert.Null(httpDetails.Exception);
|
||||
Assert.Null(httpDetails.HttpStatusCode);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -307,6 +307,25 @@ public partial class SseIntegrationTests(ITestOutputHelper outputHelper) : Kestr
|
|||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Completion_ServerShutdown_ReturnsHttpCompletionDetails()
|
||||
{
|
||||
Builder.Services.AddMcpServer().WithHttpTransport();
|
||||
await using var app = Builder.Build();
|
||||
app.MapMcp();
|
||||
await app.StartAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
var mcpClient = await ConnectMcpClientAsync();
|
||||
Assert.False(mcpClient.Completion.IsCompleted);
|
||||
|
||||
// Stop the server while the client is still connected.
|
||||
await app.StopAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
var details = await mcpClient.Completion.WaitAsync(TestContext.Current.CancellationToken);
|
||||
var httpDetails = Assert.IsType<HttpClientCompletionDetails>(details);
|
||||
Assert.Null(httpDetails.HttpStatusCode);
|
||||
}
|
||||
|
||||
public class Envelope
|
||||
{
|
||||
public required string Message { get; set; }
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Http.Json;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
|
@ -7,6 +7,7 @@ using ModelContextProtocol.Client;
|
|||
using ModelContextProtocol.Protocol;
|
||||
using ModelContextProtocol.Server;
|
||||
using ModelContextProtocol.Tests.Utils;
|
||||
using System.Net;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using System.Text.Json;
|
||||
|
|
@ -370,6 +371,159 @@ public class StreamableHttpClientConformanceTests(ITestOutputHelper outputHelper
|
|||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Completion_SessionExpiredOnPost_ReturnsHttpCompletionDetails()
|
||||
{
|
||||
bool expireSession = false;
|
||||
|
||||
Builder.Services.Configure<JsonOptions>(options =>
|
||||
{
|
||||
options.SerializerOptions.TypeInfoResolverChain.Add(McpJsonUtilities.DefaultOptions.TypeInfoResolver!);
|
||||
});
|
||||
_app = Builder.Build();
|
||||
|
||||
_app.MapPost("/mcp", (JsonRpcMessage message, HttpContext context) =>
|
||||
{
|
||||
if (message is not JsonRpcRequest request)
|
||||
{
|
||||
return Results.Accepted();
|
||||
}
|
||||
|
||||
context.Response.Headers.Append("mcp-session-id", "expiry-test-session");
|
||||
|
||||
if (expireSession)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
if (request.Method == "initialize")
|
||||
{
|
||||
return Results.Json(new JsonRpcResponse
|
||||
{
|
||||
Id = request.Id,
|
||||
Result = JsonSerializer.SerializeToNode(new InitializeResult
|
||||
{
|
||||
ProtocolVersion = "2024-11-05",
|
||||
Capabilities = new() { Tools = new() },
|
||||
ServerInfo = new Implementation { Name = "expiry-test", Version = "0.0.1" },
|
||||
}, McpJsonUtilities.DefaultOptions)
|
||||
});
|
||||
}
|
||||
|
||||
return Results.Accepted();
|
||||
});
|
||||
|
||||
await _app.StartAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
await using var transport = new HttpClientTransport(new()
|
||||
{
|
||||
Endpoint = new("http://localhost:5000/mcp"),
|
||||
TransportMode = HttpTransportMode.StreamableHttp,
|
||||
}, HttpClient, LoggerFactory);
|
||||
|
||||
var client = await McpClient.CreateAsync(transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken);
|
||||
Assert.Equal("expiry-test-session", client.SessionId);
|
||||
Assert.False(client.Completion.IsCompleted);
|
||||
|
||||
// Simulate session expiry by having the server return 404
|
||||
expireSession = true;
|
||||
|
||||
await Assert.ThrowsAnyAsync<Exception>(async () =>
|
||||
await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken));
|
||||
|
||||
var details = await client.Completion.WaitAsync(TestContext.Current.CancellationToken);
|
||||
var httpDetails = Assert.IsType<HttpClientCompletionDetails>(details);
|
||||
Assert.Equal(HttpStatusCode.NotFound, httpDetails.HttpStatusCode);
|
||||
Assert.NotNull(httpDetails.Exception);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Completion_SessionExpiredOnGet_ReturnsHttpCompletionDetails()
|
||||
{
|
||||
var expireSession = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
Builder.Services.Configure<JsonOptions>(options =>
|
||||
{
|
||||
options.SerializerOptions.TypeInfoResolverChain.Add(McpJsonUtilities.DefaultOptions.TypeInfoResolver!);
|
||||
});
|
||||
_app = Builder.Build();
|
||||
|
||||
_app.MapPost("/mcp", (JsonRpcMessage message, HttpContext context) =>
|
||||
{
|
||||
if (message is not JsonRpcRequest request)
|
||||
{
|
||||
return Results.Accepted();
|
||||
}
|
||||
|
||||
context.Response.Headers.Append("mcp-session-id", "get-expiry-test");
|
||||
|
||||
if (request.Method == "initialize")
|
||||
{
|
||||
return Results.Json(new JsonRpcResponse
|
||||
{
|
||||
Id = request.Id,
|
||||
Result = JsonSerializer.SerializeToNode(new InitializeResult
|
||||
{
|
||||
ProtocolVersion = "2024-11-05",
|
||||
Capabilities = new() { Tools = new() },
|
||||
ServerInfo = new Implementation { Name = "get-expiry-test", Version = "0.0.1" },
|
||||
}, McpJsonUtilities.DefaultOptions)
|
||||
});
|
||||
}
|
||||
|
||||
return Results.Accepted();
|
||||
});
|
||||
|
||||
// GET handler waits for the signal, then returns 404 to simulate session expiry
|
||||
_app.MapGet("/mcp", async (HttpContext context) =>
|
||||
{
|
||||
await expireSession.Task;
|
||||
context.Response.StatusCode = StatusCodes.Status404NotFound;
|
||||
});
|
||||
|
||||
await _app.StartAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
await using var transport = new HttpClientTransport(new()
|
||||
{
|
||||
Endpoint = new("http://localhost:5000/mcp"),
|
||||
TransportMode = HttpTransportMode.StreamableHttp,
|
||||
}, HttpClient, LoggerFactory);
|
||||
|
||||
var client = await McpClient.CreateAsync(transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken);
|
||||
Assert.Equal("get-expiry-test", client.SessionId);
|
||||
|
||||
// Trigger session expiry on the GET SSE stream
|
||||
expireSession.SetResult();
|
||||
|
||||
var details = await client.Completion.WaitAsync(TestContext.Current.CancellationToken);
|
||||
var httpDetails = Assert.IsType<HttpClientCompletionDetails>(details);
|
||||
Assert.Equal(HttpStatusCode.NotFound, httpDetails.HttpStatusCode);
|
||||
Assert.NotNull(httpDetails.Exception);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Completion_GracefulDisposal_ReturnsCompletionDetails()
|
||||
{
|
||||
await StartAsync(enableDelete: true);
|
||||
|
||||
await using var transport = new HttpClientTransport(new()
|
||||
{
|
||||
Endpoint = new("http://localhost:5000/mcp"),
|
||||
TransportMode = HttpTransportMode.StreamableHttp,
|
||||
}, HttpClient, LoggerFactory);
|
||||
|
||||
var client = await McpClient.CreateAsync(transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken);
|
||||
Assert.False(client.Completion.IsCompleted);
|
||||
|
||||
await client.DisposeAsync();
|
||||
Assert.True(client.Completion.IsCompleted);
|
||||
|
||||
var details = await client.Completion;
|
||||
var httpDetails = Assert.IsType<HttpClientCompletionDetails>(details);
|
||||
Assert.Null(httpDetails.Exception);
|
||||
Assert.Null(httpDetails.HttpStatusCode);
|
||||
}
|
||||
|
||||
private static async Task CallEchoAndValidateAsync(McpClientTool echoTool)
|
||||
{
|
||||
var response = await echoTool.CallAsync(new Dictionary<string, object?>() { ["message"] = "Hello world!" }, cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
|
|
|||
|
|
@ -181,6 +181,23 @@ internal static class Program
|
|||
{
|
||||
TaskSupport = ToolTaskSupport.Optional
|
||||
}
|
||||
},
|
||||
new Tool
|
||||
{
|
||||
Name = "crash",
|
||||
Description = "Terminates the server process with a specified exit code.",
|
||||
InputSchema = JsonElement.Parse("""
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"exitCode": {
|
||||
"type": "number",
|
||||
"description": "The exit code to terminate with"
|
||||
}
|
||||
},
|
||||
"required": ["exitCode"]
|
||||
}
|
||||
"""),
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -241,6 +258,17 @@ internal static class Program
|
|||
Content = [new TextContentBlock { Text = $"Long-running operation completed after {durationMs}ms" }]
|
||||
};
|
||||
}
|
||||
else if (request.Params?.Name == "crash")
|
||||
{
|
||||
if (request.Params?.Arguments is null || !request.Params.Arguments.TryGetValue("exitCode", out var exitCodeValue))
|
||||
{
|
||||
throw new McpProtocolException("Missing required argument 'exitCode'", McpErrorCode.InvalidParams);
|
||||
}
|
||||
int exitCode = Convert.ToInt32(exitCodeValue.GetRawText());
|
||||
Console.Error.WriteLine($"Crashing with exit code {exitCode}");
|
||||
Environment.Exit(exitCode);
|
||||
throw new Exception("unreachable");
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new McpProtocolException($"Unknown tool: {request.Params?.Name}", McpErrorCode.InvalidParams);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,95 @@
|
|||
using ModelContextProtocol.Client;
|
||||
|
||||
namespace ModelContextProtocol.Tests.Client;
|
||||
|
||||
public class ClientCompletionDetailsTests
|
||||
{
|
||||
[Fact]
|
||||
public void ClientCompletionDetails_PropertiesRoundtrip()
|
||||
{
|
||||
var exception = new InvalidOperationException("test");
|
||||
var details = new ClientCompletionDetails
|
||||
{
|
||||
Exception = exception,
|
||||
};
|
||||
|
||||
Assert.Same(exception, details.Exception);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClientCompletionDetails_DefaultsToNull()
|
||||
{
|
||||
var details = new ClientCompletionDetails();
|
||||
Assert.Null(details.Exception);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StdioClientCompletionDetails_PropertiesRoundtrip()
|
||||
{
|
||||
var exception = new IOException("process exited");
|
||||
string[] stderrLines = ["error line 1", "error line 2"];
|
||||
|
||||
var details = new StdioClientCompletionDetails
|
||||
{
|
||||
Exception = exception,
|
||||
ProcessId = 12345,
|
||||
ExitCode = 42,
|
||||
StandardErrorTail = stderrLines,
|
||||
};
|
||||
|
||||
Assert.Same(exception, details.Exception);
|
||||
Assert.Equal(12345, details.ProcessId);
|
||||
Assert.Equal(42, details.ExitCode);
|
||||
Assert.Same(stderrLines, details.StandardErrorTail);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StdioClientCompletionDetails_DefaultsToNull()
|
||||
{
|
||||
var details = new StdioClientCompletionDetails();
|
||||
Assert.Null(details.Exception);
|
||||
Assert.Null(details.ProcessId);
|
||||
Assert.Null(details.ExitCode);
|
||||
Assert.Null(details.StandardErrorTail);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StdioClientCompletionDetails_IsClientCompletionDetails()
|
||||
{
|
||||
ClientCompletionDetails details = new StdioClientCompletionDetails { ExitCode = 1 };
|
||||
var stdio = Assert.IsType<StdioClientCompletionDetails>(details);
|
||||
Assert.Equal(1, stdio.ExitCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HttpClientCompletionDetails_PropertiesRoundtrip()
|
||||
{
|
||||
var exception = new HttpRequestException("connection refused");
|
||||
|
||||
var details = new HttpClientCompletionDetails
|
||||
{
|
||||
Exception = exception,
|
||||
HttpStatusCode = System.Net.HttpStatusCode.NotFound,
|
||||
};
|
||||
|
||||
Assert.Same(exception, details.Exception);
|
||||
Assert.Equal(System.Net.HttpStatusCode.NotFound, details.HttpStatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HttpClientCompletionDetails_DefaultsToNull()
|
||||
{
|
||||
var details = new HttpClientCompletionDetails();
|
||||
Assert.Null(details.Exception);
|
||||
Assert.Null(details.HttpStatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HttpClientCompletionDetails_IsClientCompletionDetails()
|
||||
{
|
||||
ClientCompletionDetails details = new HttpClientCompletionDetails { HttpStatusCode = System.Net.HttpStatusCode.NotFound };
|
||||
var http = Assert.IsType<HttpClientCompletionDetails>(details);
|
||||
Assert.Equal(System.Net.HttpStatusCode.NotFound, http.HttpStatusCode);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -112,7 +112,11 @@ public class McpClientCreationTests
|
|||
|
||||
public Task<ITransport> ConnectAsync(CancellationToken cancellationToken = default) => Task.FromResult<ITransport>(this);
|
||||
|
||||
public ValueTask DisposeAsync() => default;
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
_channel.Writer.TryComplete();
|
||||
return default;
|
||||
}
|
||||
|
||||
public string Name => "Test Nop Transport";
|
||||
|
||||
|
|
|
|||
|
|
@ -794,4 +794,17 @@ public class McpClientTests : ClientServerTestBase
|
|||
Assert.NotNull(response);
|
||||
Assert.NotNull(response.Result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Completion_GracefulDisposal_CompletesWithNoException()
|
||||
{
|
||||
var client = await CreateMcpClientForServer();
|
||||
Assert.False(client.Completion.IsCompleted);
|
||||
|
||||
await client.DisposeAsync();
|
||||
Assert.True(client.Completion.IsCompleted);
|
||||
|
||||
var details = await client.Completion;
|
||||
Assert.Null(details.Exception);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue