Skip to content

Commit 009f37a

Browse files
committed
Add Idle_Sessions_ArePruned test
1 parent cae3be6 commit 009f37a

19 files changed

+290
-271
lines changed

Directory.Packages.props

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@
5454
<PackageVersion Include="Microsoft.Extensions.Logging" Version="9.0.4" />
5555
<PackageVersion Include="Microsoft.Extensions.Logging.Console" Version="9.0.4" />
5656
<PackageVersion Include="Microsoft.Extensions.Options" Version="9.0.4" />
57+
<PackageVersion Include="Microsoft.Extensions.TimeProvider.Testing" Version="9.4.0" />
5758
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
5859
<PackageVersion Include="Moq" Version="4.20.72" />
5960
<PackageVersion Include="OpenTelemetry" Version="1.11.2" />

src/ModelContextProtocol.AspNetCore/HttpMcpServerBuilderExtensions.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ public static class HttpMcpServerBuilderExtensions
2222
public static IMcpServerBuilder WithHttpTransport(this IMcpServerBuilder builder, Action<HttpServerTransportOptions>? configureOptions = null)
2323
{
2424
ArgumentNullException.ThrowIfNull(builder);
25+
2526
builder.Services.TryAddSingleton<StreamableHttpHandler>();
2627
builder.Services.TryAddSingleton<SseHandler>();
2728
builder.Services.AddHostedService<IdleTrackingBackgroundService>();

src/ModelContextProtocol.AspNetCore/HttpServerTransportOptions.cs

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,17 @@ public class HttpServerTransportOptions
2626
/// Represents the duration of time the server will wait between any active requests before timing out an
2727
/// MCP session. This is checked in background every 5 seconds. A client trying to resume a session will
2828
/// receive a 404 status code and should restart their session. A client can keep their session open by
29-
/// keeping a GET request open. The default value is set to 2 minutes.
29+
/// keeping a GET request open. The default value is set to 2 hours.
3030
/// </summary>
31-
public TimeSpan IdleTimeout { get; set; } = TimeSpan.FromMinutes(2);
31+
public TimeSpan IdleTimeout { get; set; } = TimeSpan.FromHours(2);
32+
33+
/// <summary>
34+
/// The maximum number of idle sessions to track. This is used to limit the number of sessions that can be idle at once.
35+
/// Past this limit, the server will log a critical error and terminate the oldest idle sessions even if they have not reached
36+
/// their <see cref="IdleTimeout"/> until the idle session count is below this limit. Clients that keep their session open by
37+
/// keeping a GET request open will not count towards this limit. The default value is set to 10,000 sessions.
38+
/// </summary>
39+
public int MaxIdleSessionCount { get; set; } = 10_000;
3240

3341
/// <summary>
3442
/// Used for testing the <see cref="IdleTimeout"/>.

src/ModelContextProtocol.AspNetCore/IdleTrackingBackgroundService.cs

Lines changed: 81 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -8,28 +8,39 @@ namespace ModelContextProtocol.AspNetCore;
88
internal sealed partial class IdleTrackingBackgroundService(
99
StreamableHttpHandler handler,
1010
IOptions<HttpServerTransportOptions> options,
11+
IHostApplicationLifetime appLifetime,
1112
ILogger<IdleTrackingBackgroundService> logger) : BackgroundService
1213
{
1314
// The compiler will complain about the parameter being unused otherwise despite the source generator.
1415
private ILogger _logger = logger;
1516

16-
// We can make this configurable once we properly harden the MCP server. In the meantime, anyone running
17-
// this should be taking a cattle not pets approach to their servers and be able to launch more processes
18-
// to handle more than 10,000 idle sessions at a time.
19-
private const int MaxIdleSessionCount = 10_000;
20-
2117
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
2218
{
23-
var timeProvider = options.Value.TimeProvider;
24-
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(5), timeProvider);
19+
// Still run loop given infinite IdleTimeout to enforce the MaxIdleSessionCount and assist graceful shutdown.
20+
if (options.Value.IdleTimeout != Timeout.InfiniteTimeSpan)
21+
{
22+
ArgumentOutOfRangeException.ThrowIfLessThan(options.Value.IdleTimeout, TimeSpan.Zero);
23+
}
24+
ArgumentOutOfRangeException.ThrowIfLessThan(options.Value.MaxIdleSessionCount, 0);
2525

2626
try
2727
{
28+
var timeProvider = options.Value.TimeProvider;
29+
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(5), timeProvider);
30+
31+
var idleTimeoutTicks = options.Value.IdleTimeout.Ticks;
32+
var maxIdleSessionCount = options.Value.MaxIdleSessionCount;
33+
34+
var idleSessions = new SortedSet<(string SessionId, long Timestamp)>(SessionTimestampComparer.Instance);
35+
2836
while (!stoppingToken.IsCancellationRequested && await timer.WaitForNextTickAsync(stoppingToken))
2937
{
30-
var idleActivityCutoff = timeProvider.GetTimestamp() - options.Value.IdleTimeout.Ticks;
38+
var idleActivityCutoff = idleTimeoutTicks switch
39+
{
40+
< 0 => long.MinValue,
41+
var ticks => timeProvider.GetTimestamp() - ticks,
42+
};
3143

32-
var idleCount = 0;
3344
foreach (var (_, session) in handler.Sessions)
3445
{
3546
if (session.IsActive || session.SessionClosed.IsCancellationRequested)
@@ -38,34 +49,40 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
3849
continue;
3950
}
4051

41-
idleCount++;
42-
if (idleCount == MaxIdleSessionCount)
43-
{
44-
// Emit critical log at most once every 5 seconds the idle count it exceeded,
45-
//since the IdleTimeout will no longer be respected.
46-
LogMaxSessionIdleCountExceeded();
47-
}
48-
else if (idleCount < MaxIdleSessionCount && session.LastActivityTicks > idleActivityCutoff)
52+
if (session.LastActivityTicks < idleActivityCutoff)
4953
{
54+
RemoveAndCloseSession(session.Id);
5055
continue;
5156
}
5257

53-
if (handler.Sessions.TryRemove(session.Id, out var removedSession))
58+
idleSessions.Add((session.Id, session.LastActivityTicks));
59+
60+
// Emit critical log at most once every 5 seconds the idle count it exceeded,
61+
// since the IdleTimeout will no longer be respected.
62+
if (idleSessions.Count == maxIdleSessionCount + 1)
5463
{
55-
LogSessionIdle(removedSession.Id);
64+
LogMaxSessionIdleCountExceeded(maxIdleSessionCount);
65+
}
66+
}
5667

57-
// Don't slow down the idle tracking loop. DisposeSessionAsync logs. We only await during graceful shutdown.
58-
_ = DisposeSessionAsync(removedSession);
68+
if (idleSessions.Count > maxIdleSessionCount)
69+
{
70+
var sessionsToPrune = idleSessions.ToArray()[..^maxIdleSessionCount];
71+
foreach (var (id, _) in sessionsToPrune)
72+
{
73+
RemoveAndCloseSession(id);
5974
}
6075
}
76+
77+
idleSessions.Clear();
6178
}
6279
}
6380
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
6481
{
6582
}
6683
finally
6784
{
68-
if (stoppingToken.IsCancellationRequested)
85+
try
6986
{
7087
List<Task> disposeSessionTasks = [];
7188

@@ -79,7 +96,29 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
7996

8097
await Task.WhenAll(disposeSessionTasks);
8198
}
99+
finally
100+
{
101+
if (!stoppingToken.IsCancellationRequested)
102+
{
103+
// Something went terribly wrong. A very unexpected exception must be bubbling up, but let's ensure we also stop the application,
104+
// so that it hopefully gets looked at and restarted. This shouldn't really be reachable.
105+
appLifetime.StopApplication();
106+
IdleTrackingBackgroundServiceStoppedUnexpectedly();
107+
}
108+
}
109+
}
110+
}
111+
112+
private void RemoveAndCloseSession(string sessionId)
113+
{
114+
if (!handler.Sessions.TryRemove(sessionId, out var session))
115+
{
116+
return;
82117
}
118+
119+
LogSessionIdle(session.Id);
120+
// Don't slow down the idle tracking loop. DisposeSessionAsync logs. We only await during graceful shutdown.
121+
_ = DisposeSessionAsync(session);
83122
}
84123

85124
private async Task DisposeSessionAsync(HttpMcpSession<StreamableHttpServerTransport> session)
@@ -94,12 +133,28 @@ private async Task DisposeSessionAsync(HttpMcpSession<StreamableHttpServerTransp
94133
}
95134
}
96135

136+
private sealed class SessionTimestampComparer : IComparer<(string SessionId, long Timestamp)>
137+
{
138+
public static SessionTimestampComparer Instance { get; } = new();
139+
140+
public int Compare((string SessionId, long Timestamp) x, (string SessionId, long Timestamp) y) =>
141+
x.Timestamp.CompareTo(y.Timestamp) switch
142+
{
143+
// Use a SessionId comparison as tiebreaker to ensure uniqueness in the SortedSet.
144+
0 => string.CompareOrdinal(x.SessionId, y.SessionId),
145+
var timestampComparison => timestampComparison,
146+
};
147+
}
148+
97149
[LoggerMessage(Level = LogLevel.Information, Message = "Closing idle session {sessionId}.")]
98150
private partial void LogSessionIdle(string sessionId);
99151

100-
[LoggerMessage(Level = LogLevel.Critical, Message = "Exceeded static maximum of 10,000 idle connections. Now clearing all inactive connections regardless of timeout.")]
101-
private partial void LogMaxSessionIdleCountExceeded();
102-
103-
[LoggerMessage(Level = LogLevel.Error, Message = "Error disposing the IMcpServer for session {sessionId}.")]
152+
[LoggerMessage(Level = LogLevel.Error, Message = "Error disposing session {sessionId}.")]
104153
private partial void LogSessionDisposeError(string sessionId, Exception ex);
154+
155+
[LoggerMessage(Level = LogLevel.Critical, Message = "Exceeded maximum of {maxIdleSessionCount} idle sessions. Now closing sessions active more recently than configured IdleTimeout.")]
156+
private partial void LogMaxSessionIdleCountExceeded(int maxIdleSessionCount);
157+
158+
[LoggerMessage(Level = LogLevel.Critical, Message = "The IdleTrackingBackgroundService has stopped unexpectedly.")]
159+
private partial void IdleTrackingBackgroundServiceStoppedUnexpectedly();
105160
}

src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs

Lines changed: 14 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
using Microsoft.AspNetCore.WebUtilities;
44
using Microsoft.Extensions.Logging;
55
using Microsoft.Extensions.Options;
6+
using Microsoft.Net.Http.Headers;
67
using ModelContextProtocol.Protocol.Messages;
78
using ModelContextProtocol.Protocol.Transport;
89
using ModelContextProtocol.Server;
@@ -23,6 +24,8 @@ internal sealed class StreamableHttpHandler(
2324
IServiceProvider applicationServices)
2425
{
2526
private static JsonTypeInfo<JsonRpcError> s_errorTypeInfo = GetRequiredJsonTypeInfo<JsonRpcError>();
27+
private static MediaTypeHeaderValue ApplicationJsonMediaType = new("application/json");
28+
private static MediaTypeHeaderValue TextEventStreamMediaType = new("text/event-stream");
2629

2730
public ConcurrentDictionary<string, HttpMcpSession<StreamableHttpServerTransport>> Sessions { get; } = new(StringComparer.Ordinal);
2831

@@ -32,9 +35,8 @@ public async Task HandlePostRequestAsync(HttpContext context)
3235
// ASP.NET Core Minimal APIs mostly try to stay out of the business of response content negotiation,
3336
// so we have to do this manually. The spec doesn't mandate that servers MUST reject these requests,
3437
// but it's probably good to at least start out trying to be strict.
35-
var acceptHeader = context.Request.Headers.Accept.ToString();
36-
if (!acceptHeader.Contains("application/json", StringComparison.Ordinal) ||
37-
!acceptHeader.Contains("text/event-stream", StringComparison.Ordinal))
38+
var acceptHeaders = context.Request.GetTypedHeaders().Accept;
39+
if (!acceptHeaders.Contains(ApplicationJsonMediaType) || !acceptHeaders.Contains(TextEventStreamMediaType))
3840
{
3941
await WriteJsonRpcErrorAsync(context,
4042
"Not Acceptable: Client must accept both application/json and text/event-stream",
@@ -61,8 +63,8 @@ await WriteJsonRpcErrorAsync(context,
6163

6264
public async Task HandleGetRequestAsync(HttpContext context)
6365
{
64-
var acceptHeader = context.Request.Headers.Accept.ToString();
65-
if (!acceptHeader.Contains("application/json", StringComparison.Ordinal))
66+
var acceptHeaders = context.Request.GetTypedHeaders().Accept;
67+
if (!acceptHeaders.Contains(ApplicationJsonMediaType))
6668
{
6769
await WriteJsonRpcErrorAsync(context,
6870
"Not Acceptable: Client must accept text/event-stream",
@@ -116,7 +118,8 @@ await WriteJsonRpcErrorAsync(context,
116118
return null;
117119
}
118120

119-
InitializeSessionResponse(context, existingSession);
121+
context.Response.Headers["mcp-session-id"] = existingSession.Id;
122+
context.Features.Set(existingSession.Server);
120123
return existingSession;
121124
}
122125

@@ -151,6 +154,9 @@ await WriteJsonRpcErrorAsync(context,
151154

152155
private async ValueTask<HttpMcpSession<StreamableHttpServerTransport>> CreateSessionAsync(HttpContext context)
153156
{
157+
var sessionId = MakeNewSessionId();
158+
context.Response.Headers["mcp-session-id"] = sessionId;
159+
154160
var mcpServerOptions = mcpServerOptionsSnapshot.Value;
155161
if (httpMcpServerOptions.Value.ConfigureSessionOptions is { } configureSessionOptions)
156162
{
@@ -161,16 +167,16 @@ private async ValueTask<HttpMcpSession<StreamableHttpServerTransport>> CreateSes
161167
var transport = new StreamableHttpServerTransport();
162168
// Use application instead of request services, because the session will likely outlive the first initialization request.
163169
var server = McpServerFactory.Create(transport, mcpServerOptions, loggerFactory, applicationServices);
170+
context.Features.Set(server);
164171

165-
var session = new HttpMcpSession<StreamableHttpServerTransport>(MakeNewSessionId(), transport, context.User, httpMcpServerOptions.Value.TimeProvider)
172+
var session = new HttpMcpSession<StreamableHttpServerTransport>(sessionId, transport, context.User, httpMcpServerOptions.Value.TimeProvider)
166173
{
167174
Server = server,
168175
};
169176

170177
var runSessionAsync = httpMcpServerOptions.Value.RunSessionHandler ?? RunSessionAsync;
171178
session.ServerRunTask = runSessionAsync(context, server, session.SessionClosed);
172179

173-
InitializeSessionResponse(context, session);
174180
return session;
175181
}
176182

@@ -187,12 +193,6 @@ private static Task WriteJsonRpcErrorAsync(HttpContext context, string errorMess
187193
return Results.Json(jsonRpcError, s_errorTypeInfo, statusCode: statusCode).ExecuteAsync(context);
188194
}
189195

190-
private void InitializeSessionResponse(HttpContext context, HttpMcpSession<StreamableHttpServerTransport> session)
191-
{
192-
context.Response.Headers["mcp-session-id"] = session.Id;
193-
context.Features.Set(session.Server);
194-
}
195-
196196
internal static void InitializeSseResponse(HttpContext context)
197197
{
198198
context.Response.Headers.ContentType = "text/event-stream";
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
using Microsoft.Extensions.Logging;
2+
using System.Collections.Concurrent;
3+
4+
namespace ModelContextProtocol.Tests.Utils;
5+
6+
public class MockLoggerProvider() : ILoggerProvider
7+
{
8+
public ConcurrentQueue<(string Category, LogLevel LogLevel, string Message, Exception? Exception)> LogMessages { get; } = [];
9+
10+
public ILogger CreateLogger(string categoryName)
11+
{
12+
return new MockLogger(this, categoryName);
13+
}
14+
15+
public void Dispose()
16+
{
17+
}
18+
19+
private class MockLogger(MockLoggerProvider mockProvider, string category) : ILogger
20+
{
21+
public void Log<TState>(
22+
LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter)
23+
{
24+
mockProvider.LogMessages.Enqueue((category, logLevel, formatter(state, exception), exception));
25+
}
26+
27+
public bool IsEnabled(LogLevel logLevel) => true;
28+
29+
// The MockLoggerProvider is a convenient NoopDisposable
30+
public IDisposable BeginScope<TState>(TState state) where TState : notnull => mockProvider;
31+
}
32+
}

tests/ModelContextProtocol.AspNetCore.Tests/ModelContextProtocol.AspNetCore.Tests.csproj

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,10 @@
2222
<DisableTestingPlatformServerCapability>true</DisableTestingPlatformServerCapability>
2323
</PropertyGroup>
2424

25+
<ItemGroup>
26+
<Compile Include="..\Common\**\*.cs" />
27+
</ItemGroup>
28+
2529
<ItemGroup>
2630
<PackageReference Include="coverlet.collector">
2731
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
@@ -35,6 +39,7 @@
3539
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" />
3640
<PackageReference Include="Microsoft.Extensions.Logging" />
3741
<PackageReference Include="Microsoft.Extensions.Logging.Console" />
42+
<PackageReference Include="Microsoft.Extensions.TimeProvider.Testing" />
3843
<PackageReference Include="Microsoft.NET.Test.Sdk" />
3944
<PackageReference Include="Moq" />
4045
<PackageReference Include="OpenTelemetry" />

0 commit comments

Comments
 (0)