Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,10 @@ namespace ModelContextProtocol.AspNetCore.Tests;
public class StreamableHttpClientConformanceTests(ITestOutputHelper outputHelper) : KestrelInMemoryTest(outputHelper), IAsyncDisposable
{
private WebApplication? _app;
private readonly List<string> _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<JsonOptions>(options =>
{
Expand All @@ -28,14 +30,20 @@ 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)
{
// Ignore all non-request notifications.
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
Expand Down Expand Up @@ -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);
}

Expand Down Expand Up @@ -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<string, object?>() { ["message"] = "Hello world!" }, cancellationToken: TestContext.Current.CancellationToken);
Expand Down
Loading