Skip to content

Commit 0fd7769

Browse files
Add TimeProvider support and test for identical timestamp handling
Co-authored-by: eiriktsarpalis <2813363+eiriktsarpalis@users.noreply.github.com>
1 parent fba8ccf commit 0fd7769

5 files changed

Lines changed: 67 additions & 7 deletions

File tree

Directory.Packages.props

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
<!-- Product dependencies .NET Standard -->
2727
<ItemGroup Condition="'$(TargetFramework)' == 'netstandard2.0'">
2828
<PackageVersion Include="Microsoft.Bcl.Memory" Version="$(System10Version)" />
29+
<PackageVersion Include="Microsoft.Bcl.TimeProvider" Version="$(System10Version)" />
2930
<PackageVersion Include="System.Diagnostics.DiagnosticSource" Version="$(System10Version)" />
3031
<PackageVersion Include="System.IO.Pipelines" Version="$(System10Version)" />
3132
<PackageVersion Include="System.Text.Json" Version="$(System10Version)" />

src/ModelContextProtocol.Core/ModelContextProtocol.Core.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
<ItemGroup Condition="'$(TargetFramework)' == 'netstandard2.0'">
3434
<Compile Include="..\Common\CancellableStreamReader\**\*.cs" />
3535
<PackageReference Include="Microsoft.Bcl.Memory" />
36+
<PackageReference Include="Microsoft.Bcl.TimeProvider" />
3637
<PackageReference Include="System.Diagnostics.DiagnosticSource" />
3738
<PackageReference Include="System.Text.Json" />
3839
<PackageReference Include="System.Threading.Channels" />

src/ModelContextProtocol.Core/Server/InMemoryMcpTaskStore.cs

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ public sealed class InMemoryMcpTaskStore : IMcpTaskStore, IDisposable
3535
private readonly int _pageSize;
3636
private readonly int? _maxTasks;
3737
private readonly int? _maxTasksPerSession;
38+
private readonly TimeProvider _timeProvider;
3839

3940
/// <summary>
4041
/// Initializes a new instance of the <see cref="InMemoryMcpTaskStore"/> class.
@@ -65,14 +66,19 @@ public sealed class InMemoryMcpTaskStore : IMcpTaskStore, IDisposable
6566
/// Maximum number of tasks allowed per session. Null means unlimited.
6667
/// When the limit is reached for a session, <see cref="CreateTaskAsync"/> will throw <see cref="InvalidOperationException"/>.
6768
/// </param>
69+
/// <param name="timeProvider">
70+
/// Time provider for getting the current time. Defaults to <see cref="TimeProvider.System"/>.
71+
/// This parameter is primarily useful for testing scenarios where you need to control time.
72+
/// </param>
6873
public InMemoryMcpTaskStore(
6974
TimeSpan? defaultTtl = null,
7075
TimeSpan? maxTtl = null,
7176
TimeSpan? pollInterval = null,
7277
TimeSpan? cleanupInterval = null,
7378
int pageSize = 100,
7479
int? maxTasks = null,
75-
int? maxTasksPerSession = null)
80+
int? maxTasksPerSession = null,
81+
TimeProvider? timeProvider = null)
7682
{
7783
if (defaultTtl.HasValue && maxTtl.HasValue && defaultTtl.Value > maxTtl.Value)
7884
{
@@ -120,6 +126,7 @@ public InMemoryMcpTaskStore(
120126
_pageSize = pageSize;
121127
_maxTasks = maxTasks;
122128
_maxTasksPerSession = maxTasksPerSession;
129+
_timeProvider = timeProvider ?? TimeProvider.System;
123130

124131
cleanupInterval ??= TimeSpan.FromMinutes(1);
125132
if (cleanupInterval.Value != Timeout.InfiniteTimeSpan)
@@ -155,7 +162,7 @@ public Task<McpTask> CreateTaskAsync(
155162
}
156163

157164
var taskId = GenerateTaskId();
158-
var now = DateTimeOffset.UtcNow;
165+
var now = _timeProvider.GetUtcNow();
159166

160167
// Determine TTL: use requested, fall back to default, respect max limit
161168
var ttl = taskParams.TimeToLive ?? _defaultTtl;
@@ -242,7 +249,7 @@ public Task<McpTask> StoreTaskResultAsync(
242249
var updatedEntry = new TaskEntry(entry)
243250
{
244251
Status = status,
245-
LastUpdatedAt = DateTimeOffset.UtcNow,
252+
LastUpdatedAt = _timeProvider.GetUtcNow(),
246253
StoredResult = result
247254
};
248255

@@ -303,7 +310,7 @@ public Task<McpTask> UpdateTaskStatusAsync(
303310
{
304311
Status = status,
305312
StatusMessage = statusMessage,
306-
LastUpdatedAt = DateTimeOffset.UtcNow,
313+
LastUpdatedAt = _timeProvider.GetUtcNow(),
307314
};
308315

309316
if (_tasks.TryUpdate(taskId, updatedEntry, entry))
@@ -398,7 +405,7 @@ public Task<McpTask> CancelTaskAsync(string taskId, string? sessionId = null, Ca
398405
var updatedEntry = new TaskEntry(entry)
399406
{
400407
Status = McpTaskStatus.Cancelled,
401-
LastUpdatedAt = DateTimeOffset.UtcNow,
408+
LastUpdatedAt = _timeProvider.GetUtcNow(),
402409
};
403410

404411
if (_tasks.TryUpdate(taskId, updatedEntry, entry))
@@ -423,15 +430,15 @@ public void Dispose()
423430
private static bool IsTerminalStatus(McpTaskStatus status) =>
424431
status is McpTaskStatus.Completed or McpTaskStatus.Failed or McpTaskStatus.Cancelled;
425432

426-
private static bool IsExpired(TaskEntry entry)
433+
private bool IsExpired(TaskEntry entry)
427434
{
428435
if (entry.TimeToLive == null)
429436
{
430437
return false; // Unlimited lifetime
431438
}
432439

433440
var expirationTime = entry.CreatedAt + entry.TimeToLive.Value;
434-
return DateTimeOffset.UtcNow >= expirationTime;
441+
return _timeProvider.GetUtcNow() >= expirationTime;
435442
}
436443

437444
private void CleanupExpiredTasks(object? state)

tests/ModelContextProtocol.Tests/ModelContextProtocol.Tests.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" />
4444
<PackageReference Include="Microsoft.Extensions.Logging" />
4545
<PackageReference Include="Microsoft.Extensions.Logging.Console" />
46+
<PackageReference Include="Microsoft.Extensions.TimeProvider.Testing" />
4647
<PackageReference Include="Microsoft.NET.Test.Sdk" />
4748
<PackageReference Include="Moq" />
4849
<PackageReference Include="OpenTelemetry" />

tests/ModelContextProtocol.Tests/Server/InMemoryMcpTaskStoreTests.cs

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
using Microsoft.Extensions.Time.Testing;
12
using ModelContextProtocol.Protocol;
23
using ModelContextProtocol.Server;
34
using ModelContextProtocol.Tests.Utils;
@@ -1031,4 +1032,53 @@ public async Task CreateTaskAsync_MaxTasksPerSession_ExcludesExpiredTasks()
10311032
// Assert
10321033
Assert.NotNull(task2);
10331034
}
1035+
1036+
[Fact]
1037+
public async Task ListTasksAsync_KeysetPaginationWorksWithIdenticalTimestamps()
1038+
{
1039+
// Arrange - Use a fake time provider to create tasks with identical timestamps
1040+
var fakeTime = new FakeTimeProvider(DateTimeOffset.UtcNow);
1041+
using var store = new InMemoryMcpTaskStore(pageSize: 5, timeProvider: fakeTime);
1042+
1043+
// Create 10 tasks - all with the EXACT same timestamp
1044+
var createdTasks = new List<McpTask>();
1045+
for (int i = 0; i < 10; i++)
1046+
{
1047+
var task = await store.CreateTaskAsync(
1048+
new McpTaskMetadata(),
1049+
new RequestId($"req-{i}"),
1050+
new JsonRpcRequest { Method = "test" },
1051+
null,
1052+
TestContext.Current.CancellationToken);
1053+
createdTasks.Add(task);
1054+
}
1055+
1056+
// Verify all tasks have the same CreatedAt timestamp
1057+
var firstTimestamp = createdTasks[0].CreatedAt;
1058+
Assert.All(createdTasks, task => Assert.Equal(firstTimestamp, task.CreatedAt));
1059+
1060+
// Act - Get first page
1061+
var result1 = await store.ListTasksAsync(cancellationToken: TestContext.Current.CancellationToken);
1062+
1063+
// Assert - First page should have 5 tasks
1064+
Assert.Equal(5, result1.Tasks.Length);
1065+
Assert.NotNull(result1.NextCursor);
1066+
1067+
// Get second page using cursor
1068+
var result2 = await store.ListTasksAsync(cursor: result1.NextCursor, cancellationToken: TestContext.Current.CancellationToken);
1069+
1070+
// Assert - Second page should have 5 tasks
1071+
Assert.Equal(5, result2.Tasks.Length);
1072+
Assert.Null(result2.NextCursor); // No more pages
1073+
1074+
// Verify no overlap between pages
1075+
var page1Ids = result1.Tasks.Select(t => t.TaskId).ToHashSet();
1076+
var page2Ids = result2.Tasks.Select(t => t.TaskId).ToHashSet();
1077+
Assert.Empty(page1Ids.Intersect(page2Ids));
1078+
1079+
// Verify we got all 10 tasks exactly once
1080+
var allReturnedIds = page1Ids.Union(page2Ids).ToHashSet();
1081+
var allCreatedIds = createdTasks.Select(t => t.TaskId).ToHashSet();
1082+
Assert.Equal(allCreatedIds, allReturnedIds);
1083+
}
10341084
}

0 commit comments

Comments
 (0)