diff --git a/src/ModelContextProtocol.Core/Client/StreamableHttpClientSessionTransport.cs b/src/ModelContextProtocol.Core/Client/StreamableHttpClientSessionTransport.cs index e99aa2ae..50eb0e82 100644 --- a/src/ModelContextProtocol.Core/Client/StreamableHttpClientSessionTransport.cs +++ b/src/ModelContextProtocol.Core/Client/StreamableHttpClientSessionTransport.cs @@ -141,6 +141,16 @@ public override async ValueTask DisposeAsync() try { + // Send DELETE request to terminate the session only send if we have a session ID per MCP spec + if (!string.IsNullOrEmpty(_mcpSessionId)) + { + using var deleteRequest = new HttpRequestMessage(HttpMethod.Delete, _options.Endpoint); + CopyAdditionalHeaders(deleteRequest.Headers, _options.AdditionalHeaders, _mcpSessionId); + + // Do not validate we get a successful status code, because server support for the DELETE request is optional + using var deleteResponse = await _httpClient.SendAsync(deleteRequest, CancellationToken.None).ConfigureAwait(false); + } + if (_getReceiveTask != null) { await _getReceiveTask.ConfigureAwait(false); diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/StreamableHttpClientConformanceTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/StreamableHttpClientConformanceTests.cs index d7f8433b..9fb9fc5e 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/StreamableHttpClientConformanceTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/StreamableHttpClientConformanceTests.cs @@ -14,8 +14,10 @@ namespace ModelContextProtocol.AspNetCore.Tests; public class StreamableHttpClientConformanceTests(ITestOutputHelper outputHelper) : KestrelInMemoryTest(outputHelper), IAsyncDisposable { private WebApplication? _app; + private readonly List _deleteRequestSessionIds = []; - private async Task StartAsync() + // Don't add the delete endpoint by default to ensure the client still works with basic sessionless servers. + private async Task StartAsync(bool enableDelete = false) { Builder.Services.Configure(options => { @@ -28,7 +30,7 @@ private async Task StartAsync() Services = _app.Services, }); - _app.MapPost("/mcp", (JsonRpcMessage message) => + _app.MapPost("/mcp", (JsonRpcMessage message, HttpContext context) => { if (message is not JsonRpcRequest request) { @@ -36,6 +38,12 @@ private async Task StartAsync() return Results.Accepted(); } + if (enableDelete) + { + // Add a session ID to the response to enable session tracking + context.Response.Headers.Append("mcp-session-id", "test-session-123"); + } + if (request.Method == "initialize") { return Results.Json(new JsonRpcResponse @@ -87,6 +95,15 @@ private async Task StartAsync() throw new Exception("Unexpected message!"); }); + if (enableDelete) + { + _app.MapDelete("/mcp", context => + { + _deleteRequestSessionIds.Add(context.Request.Headers["mcp-session-id"].ToString()); + return Task.CompletedTask; + }); + } + await _app.StartAsync(TestContext.Current.CancellationToken); } @@ -136,6 +153,27 @@ public async Task CanCallToolConcurrently() await Task.WhenAll(echoTasks); } + [Fact] + public async Task SendsDeleteRequestOnDispose() + { + await StartAsync(enableDelete: true); + + await using var transport = new SseClientTransport(new() + { + Endpoint = new("http://localhost/mcp"), + TransportMode = HttpTransportMode.StreamableHttp, + }, HttpClient, LoggerFactory); + + await using var client = await McpClientFactory.CreateAsync(transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken); + + // Dispose should trigger DELETE request + await client.DisposeAsync(); + + // Verify DELETE request was sent with correct session ID + var sessionId = Assert.Single(_deleteRequestSessionIds); + Assert.Equal("test-session-123", sessionId); + } + private static async Task CallEchoAndValidateAsync(McpClientTool echoTool) { var response = await echoTool.CallAsync(new Dictionary() { ["message"] = "Hello world!" }, cancellationToken: TestContext.Current.CancellationToken);