Skip to content

Commit a4b4eda

Browse files
authored
Send DELETE request when closing a Streamable HTTP session on the client (#501)
1 parent befa31d commit a4b4eda

File tree

4 files changed

+93
-11
lines changed

4 files changed

+93
-11
lines changed

src/ModelContextProtocol.Core/Client/SseClientSessionTransport.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ public override async Task SendMessageAsync(
9191
{
9292
Content = content,
9393
};
94-
StreamableHttpClientSessionTransport.CopyAdditionalHeaders(httpRequestMessage.Headers, _options.AdditionalHeaders);
94+
StreamableHttpClientSessionTransport.CopyAdditionalHeaders(httpRequestMessage.Headers, _options.AdditionalHeaders, sessionId: null, protocolVersion: null);
9595
var response = await _httpClient.SendAsync(httpRequestMessage, cancellationToken).ConfigureAwait(false);
9696

9797
if (!response.IsSuccessStatusCode)
@@ -152,7 +152,7 @@ private async Task ReceiveMessagesAsync(CancellationToken cancellationToken)
152152
{
153153
using var request = new HttpRequestMessage(HttpMethod.Get, _sseEndpoint);
154154
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("text/event-stream"));
155-
StreamableHttpClientSessionTransport.CopyAdditionalHeaders(request.Headers, _options.AdditionalHeaders);
155+
StreamableHttpClientSessionTransport.CopyAdditionalHeaders(request.Headers, _options.AdditionalHeaders, sessionId: null, protocolVersion: null);
156156

157157
using var response = await _httpClient.SendAsync(
158158
request,

src/ModelContextProtocol.Core/Client/StreamableHttpClientSessionTransport.cs

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,9 @@ internal sealed partial class StreamableHttpClientSessionTransport : TransportBa
3030
private string? _negotiatedProtocolVersion;
3131
private Task? _getReceiveTask;
3232

33+
private readonly SemaphoreSlim _disposeLock = new(1, 1);
34+
private bool _disposed;
35+
3336
public StreamableHttpClientSessionTransport(
3437
string endpointName,
3538
SseClientTransportOptions transportOptions,
@@ -138,12 +141,26 @@ internal async Task<HttpResponseMessage> SendHttpRequestAsync(JsonRpcMessage mes
138141

139142
public override async ValueTask DisposeAsync()
140143
{
144+
using var _ = await _disposeLock.LockAsync().ConfigureAwait(false);
145+
146+
if (_disposed)
147+
{
148+
return;
149+
}
150+
_disposed = true;
151+
141152
try
142153
{
143154
await _connectionCts.CancelAsync().ConfigureAwait(false);
144155

145156
try
146157
{
158+
// Send DELETE request to terminate the session. Only send if we have a session ID, per MCP spec.
159+
if (!string.IsNullOrEmpty(SessionId))
160+
{
161+
await SendDeleteRequest();
162+
}
163+
147164
if (_getReceiveTask != null)
148165
{
149166
await _getReceiveTask.ConfigureAwait(false);
@@ -236,6 +253,22 @@ message is JsonRpcMessageWithId rpcResponseOrError &&
236253
return null;
237254
}
238255

256+
private async Task SendDeleteRequest()
257+
{
258+
using var deleteRequest = new HttpRequestMessage(HttpMethod.Delete, _options.Endpoint);
259+
CopyAdditionalHeaders(deleteRequest.Headers, _options.AdditionalHeaders, SessionId, _negotiatedProtocolVersion);
260+
261+
try
262+
{
263+
// Do not validate we get a successful status code, because server support for the DELETE request is optional
264+
(await _httpClient.SendAsync(deleteRequest, CancellationToken.None).ConfigureAwait(false)).Dispose();
265+
}
266+
catch (Exception ex)
267+
{
268+
LogTransportShutdownFailed(Name, ex);
269+
}
270+
}
271+
239272
private void LogJsonException(JsonException ex, string data)
240273
{
241274
if (_logger.IsEnabled(LogLevel.Trace))
@@ -251,8 +284,8 @@ private void LogJsonException(JsonException ex, string data)
251284
internal static void CopyAdditionalHeaders(
252285
HttpRequestHeaders headers,
253286
IDictionary<string, string>? additionalHeaders,
254-
string? sessionId = null,
255-
string? protocolVersion = null)
287+
string? sessionId,
288+
string? protocolVersion)
256289
{
257290
if (sessionId is not null)
258291
{

tests/ModelContextProtocol.AspNetCore.Tests/StreamableHttpClientConformanceTests.cs

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,10 @@ namespace ModelContextProtocol.AspNetCore.Tests;
1414
public class StreamableHttpClientConformanceTests(ITestOutputHelper outputHelper) : KestrelInMemoryTest(outputHelper), IAsyncDisposable
1515
{
1616
private WebApplication? _app;
17+
private readonly List<string> _deleteRequestSessionIds = [];
1718

18-
private async Task StartAsync()
19+
// Don't add the delete endpoint by default to ensure the client still works with basic sessionless servers.
20+
private async Task StartAsync(bool enableDelete = false)
1921
{
2022
Builder.Services.Configure<JsonOptions>(options =>
2123
{
@@ -28,14 +30,20 @@ private async Task StartAsync()
2830
Services = _app.Services,
2931
});
3032

31-
_app.MapPost("/mcp", (JsonRpcMessage message) =>
33+
_app.MapPost("/mcp", (JsonRpcMessage message, HttpContext context) =>
3234
{
3335
if (message is not JsonRpcRequest request)
3436
{
3537
// Ignore all non-request notifications.
3638
return Results.Accepted();
3739
}
3840

41+
if (enableDelete)
42+
{
43+
// Add a session ID to the response to enable session tracking
44+
context.Response.Headers.Append("mcp-session-id", "test-session-123");
45+
}
46+
3947
if (request.Method == "initialize")
4048
{
4149
return Results.Json(new JsonRpcResponse
@@ -87,6 +95,15 @@ private async Task StartAsync()
8795
throw new Exception("Unexpected message!");
8896
});
8997

98+
if (enableDelete)
99+
{
100+
_app.MapDelete("/mcp", context =>
101+
{
102+
_deleteRequestSessionIds.Add(context.Request.Headers["mcp-session-id"].ToString());
103+
return Task.CompletedTask;
104+
});
105+
}
106+
90107
await _app.StartAsync(TestContext.Current.CancellationToken);
91108
}
92109

@@ -136,6 +153,27 @@ public async Task CanCallToolConcurrently()
136153
await Task.WhenAll(echoTasks);
137154
}
138155

156+
[Fact]
157+
public async Task SendsDeleteRequestOnDispose()
158+
{
159+
await StartAsync(enableDelete: true);
160+
161+
await using var transport = new SseClientTransport(new()
162+
{
163+
Endpoint = new("http://localhost/mcp"),
164+
TransportMode = HttpTransportMode.StreamableHttp,
165+
}, HttpClient, LoggerFactory);
166+
167+
await using var client = await McpClientFactory.CreateAsync(transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken);
168+
169+
// Dispose should trigger DELETE request
170+
await client.DisposeAsync();
171+
172+
// Verify DELETE request was sent with correct session ID
173+
var sessionId = Assert.Single(_deleteRequestSessionIds);
174+
Assert.Equal("test-session-123", sessionId);
175+
}
176+
139177
private static async Task CallEchoAndValidateAsync(McpClientTool echoTool)
140178
{
141179
var response = await echoTool.CallAsync(new Dictionary<string, object?>() { ["message"] = "Hello world!" }, cancellationToken: TestContext.Current.CancellationToken);

tests/ModelContextProtocol.AspNetCore.Tests/Utils/KestrelInMemoryConnection.cs

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -65,10 +65,21 @@ private class DuplexStream(IDuplexPipe duplexPipe, CancellationTokenSource conne
6565
public override bool CanWrite => true;
6666
public override bool CanSeek => false;
6767

68-
public override Task<int> ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken)
69-
=> _readStream.ReadAsync(buffer, offset, count, cancellationToken);
70-
public override ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken = default)
71-
=> _readStream.ReadAsync(buffer, cancellationToken);
68+
public override async Task<int> ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken)
69+
{
70+
// Normally, Kestrel will trigger RequestAborted when the connectionClosedCts fires causing it to gracefully close
71+
// the connection. However, there's currently a race condition that can cause this to get missed. This at least
72+
// unblocks HttpConnection.SendAsync when it disposes the underlying connection stream while awaiting the _readAheadTask
73+
// as would happen with a real socket. https://github.com/dotnet/aspnetcore/pull/62385
74+
using var linkedTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, connectionClosedCts.Token);
75+
return await _readStream.ReadAsync(buffer, offset, count, linkedTokenSource.Token);
76+
}
77+
78+
public override async ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken = default)
79+
{
80+
using var linkedTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, connectionClosedCts.Token);
81+
return await _readStream.ReadAsync(buffer, linkedTokenSource.Token);
82+
}
7283

7384
public override Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken)
7485
=> _writeStream.WriteAsync(buffer, offset, count, cancellationToken);
@@ -81,7 +92,7 @@ public override Task FlushAsync(CancellationToken cancellationToken)
8192
protected override void Dispose(bool disposing)
8293
{
8394
// Signal to the server the the client has closed the connection, and dispose the client-half of the Pipes.
84-
connectionClosedCts.Cancel();
95+
ThreadPool.UnsafeQueueUserWorkItem(static cts => ((CancellationTokenSource)cts!).Cancel(), connectionClosedCts);
8596
duplexPipe.Input.Complete();
8697
duplexPipe.Output.Complete();
8798
}

0 commit comments

Comments
 (0)