Add client completion notification and details (#1368)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Stephen Toub 2026-03-02 09:17:22 -05:00 • committed by GitHub
commit b965a7eca6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 691 additions and 31 deletions

View file

@ -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; }
}

View file

@ -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; }
}

View file

@ -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; }
}

View file

@ -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}'.")]

View file

@ -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()));
}
}

View file

@ -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; }
}

View file

@ -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;
}
}

View file

@ -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
{

View file

@ -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);
}
}

View file

@ -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;
}

View 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>

View file

@ -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.
}
}

View file

@ -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)";

View file

@ -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);
}
}

View file

@ -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; }

View file

@ -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);

View file

@ -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);

View file

@ -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);
}
}

View file

@ -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";

View file

@ -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