Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
11 changes: 10 additions & 1 deletion src/ModelContextProtocol.AspNetCore/StatefulSessionManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ internal sealed partial class StatefulSessionManager(

private readonly TimeProvider _timeProvider = httpServerTransportOptions.Value.TimeProvider;
private readonly TimeSpan _idleTimeout = httpServerTransportOptions.Value.IdleTimeout;
private readonly long _idleTimeoutTicks = httpServerTransportOptions.Value.IdleTimeout.Ticks;
private readonly long _idleTimeoutTicks = GetIdleTimeoutInTimestampTicks(httpServerTransportOptions.Value.IdleTimeout, httpServerTransportOptions.Value.TimeProvider);
private readonly int _maxIdleSessionCount = httpServerTransportOptions.Value.MaxIdleSessionCount;

private readonly object _idlePruningLock = new();
Expand Down Expand Up @@ -229,6 +229,15 @@ private async Task DisposeSessionAsync(StreamableHttpSession session)
}
}

private static long GetIdleTimeoutInTimestampTicks(TimeSpan idleTimeout, TimeProvider timeProvider)
{
// Convert TimeSpan.Ticks (100-nanosecond intervals) to timestamp ticks based on TimeProvider.TimestampFrequency.
// TimeSpan.Ticks uses a fixed frequency of 10,000,000 ticks per second (100ns intervals).
// TimeProvider.GetTimestamp() returns ticks based on TimeProvider.TimestampFrequency, which varies by platform
// (e.g., ~1,000,000,000 on macOS using nanoseconds, ~10,000,000 on Windows using 100ns intervals).
return (long)(idleTimeout.Ticks * timeProvider.TimestampFrequency / (double)TimeSpan.TicksPerSecond);
}

[LoggerMessage(Level = LogLevel.Information, Message = "IdleTimeout of {IdleTimeout} exceeded. Closing idle session {SessionId}.")]
private partial void LogIdleSessionTimeout(string sessionId, TimeSpan idleTimeout);

Expand Down
2 changes: 2 additions & 0 deletions src/ModelContextProtocol.AspNetCore/StreamableHttpSession.cs
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ public async ValueTask<IAsyncDisposable> AcquireReferenceAsync(CancellationToken
{
sessionManager.DecrementIdleSessionCount();
}
// Update LastActivityTicks when acquiring reference in Started state to prevent timeout during active usage
LastActivityTicks = sessionManager.TimeProvider.GetTimestamp();
break;
case SessionState.Disposed:
throw new ObjectDisposedException(nameof(StreamableHttpSession));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -521,6 +521,33 @@ public async Task IdleSessionsPastMaxIdleSessionCount_ArePruned_LongestIdleFirst
Assert.StartsWith("MaxIdleSessionCount of 2 exceeded. Closing idle session", idleLimitLogMessage.Message);
}

[Fact]
public async Task ActiveSession_WithPeriodicRequests_DoesNotTimeout()
{
var fakeTimeProvider = new FakeTimeProvider();
Builder.Services.AddMcpServer().WithHttpTransport(options =>
{
options.IdleTimeout = TimeSpan.FromHours(2);
options.TimeProvider = fakeTimeProvider;
});

await StartAsync();
await CallInitializeAndValidateAsync();

// Simulate multiple POST requests over a period longer than IdleTimeout
// Each request should update LastActivityTicks, preventing timeout
for (int i = 0; i < 5; i++)
{
// Advance time by 1 hour between requests
fakeTimeProvider.Advance(TimeSpan.FromHours(1));
await CallEchoAndValidateAsync();
}

// Total time elapsed: 5 hours (> 2 hour IdleTimeout)
// But session should still be alive because of periodic activity
await CallEchoAndValidateAsync();
}

[Fact]
public async Task McpServer_UsedOutOfScope_CanSendNotifications()
{
Expand Down
Loading