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
13 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)";
|
||||
|
|
|
|||
Loading…
Reference in a new issue