Fix keyset pagination with monotonic UUIDv7-like task IDs (#1215)

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: eiriktsarpalis <2813363+eiriktsarpalis@users.noreply.github.com>
Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com>
Co-authored-by: Stephen Toub <stoub@microsoft.com>
This commit is contained in:
Copilot 2026-01-30 17:09:24 -05:00 • committed by GitHub
commit e47f88419c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 247 additions and 25 deletions

View file

@ -0,0 +1,40 @@
using System.Threading;
namespace System;
/// <summary>
/// Provides helper methods for monotonic ID generation.
/// </summary>
internal static class IdHelpers
{
private static long s_counter;
/// <summary>
/// Creates a strictly monotonically increasing identifier string using 64-bit timestamp ticks
/// and a 64-bit counter, formatted as a 32-character hexadecimal string (GUID-like).
/// </summary>
/// <param name="timestamp">The timestamp to embed in the identifier.</param>
/// <returns>A new strictly monotonically increasing identifier string.</returns>
/// <remarks>
/// <para>
/// This method creates a 128-bit identifier composed of two 64-bit values:
/// - High 64 bits: <see cref="DateTimeOffset.Ticks"/> from the timestamp
/// - Low 64 bits: A globally monotonically increasing counter
/// </para>
/// <para>
/// The resulting string is strictly monotonically increasing when compared lexicographically,
/// which is required for keyset pagination to work correctly. Unlike <c>Guid.CreateVersion7</c>,
/// which uses random bits for intra-millisecond uniqueness, this implementation guarantees
/// strict ordering for all identifiers regardless of when they were created.
/// </para>
/// </remarks>
public static string CreateMonotonicId(DateTimeOffset timestamp)
{
long ticks = timestamp.UtcTicks;
long counter = Interlocked.Increment(ref s_counter);
// Format as 32-character hex string (16 bytes = 128 bits)
// High 64 bits: timestamp ticks, Low 64 bits: counter
return $"{ticks:x16}{counter:x16}";
}
}

View file

@ -7,7 +7,6 @@
<PackageId>ModelContextProtocol.Core</PackageId>
<Description>Core .NET SDK for the Model Context Protocol (MCP)</Description>
<PackageReadmeFile>README.md</PackageReadmeFile>
<AllowUnsafeBlocks>True</AllowUnsafeBlocks>
<!-- Suppress the experimental tasks warning -->
<NoWarn>$(NoWarn);MCPEXP001</NoWarn>
</PropertyGroup>
@ -19,6 +18,7 @@
<PropertyGroup Condition="'$(TargetFramework)' == 'netstandard2.0'">
<!-- CS0436: Allow ObsoleteAttribute to be redefined internally -->
<NoWarn>$(NoWarn);CS0436</NoWarn>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup>
<ItemGroup>

View file

@ -3,7 +3,11 @@ using System.Collections.Concurrent;
using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
#if MCP_TEST_TIME_PROVIDER
namespace ModelContextProtocol.Tests.Internal;
#else
namespace ModelContextProtocol;
#endif
/// <summary>
/// Provides an in-memory implementation of <see cref="IMcpTaskStore"/> for development and testing.
@ -35,6 +39,9 @@ public sealed class InMemoryMcpTaskStore : IMcpTaskStore, IDisposable
private readonly int _pageSize;
private readonly int? _maxTasks;
private readonly int? _maxTasksPerSession;
#if MCP_TEST_TIME_PROVIDER
private readonly TimeProvider _timeProvider;
#endif
/// <summary>
/// Initializes a new instance of the <see cref="InMemoryMcpTaskStore"/> class.
@ -120,6 +127,9 @@ public sealed class InMemoryMcpTaskStore : IMcpTaskStore, IDisposable
_pageSize = pageSize;
_maxTasks = maxTasks;
_maxTasksPerSession = maxTasksPerSession;
#if MCP_TEST_TIME_PROVIDER
_timeProvider = TimeProvider.System;
#endif
cleanupInterval ??= TimeSpan.FromMinutes(1);
if (cleanupInterval.Value != Timeout.InfiniteTimeSpan)
@ -128,6 +138,26 @@ public sealed class InMemoryMcpTaskStore : IMcpTaskStore, IDisposable
}
}
#if MCP_TEST_TIME_PROVIDER
/// <summary>
/// Initializes a new instance of the <see cref="InMemoryMcpTaskStore"/> class with a custom time provider.
/// This constructor is only available for testing purposes.
/// </summary>
internal InMemoryMcpTaskStore(
TimeSpan? defaultTtl,
TimeSpan? maxTtl,
TimeSpan? pollInterval,
TimeSpan? cleanupInterval,
int pageSize,
int? maxTasks,
int? maxTasksPerSession,
TimeProvider timeProvider)
: this(defaultTtl, maxTtl, pollInterval, cleanupInterval, pageSize, maxTasks, maxTasksPerSession)
{
_timeProvider = timeProvider ?? TimeProvider.System;
}
#endif
/// <inheritdoc/>
public Task<McpTask> CreateTaskAsync(
McpTaskMetadata taskParams,
@ -155,7 +185,7 @@ public sealed class InMemoryMcpTaskStore : IMcpTaskStore, IDisposable
}
var taskId = GenerateTaskId();
var now = DateTimeOffset.UtcNow;
var now = GetUtcNow();
// Determine TTL: use requested, fall back to default, respect max limit
var ttl = taskParams.TimeToLive ?? _defaultTtl;
@ -242,7 +272,7 @@ public sealed class InMemoryMcpTaskStore : IMcpTaskStore, IDisposable
var updatedEntry = new TaskEntry(entry)
{
Status = status,
LastUpdatedAt = DateTimeOffset.UtcNow,
LastUpdatedAt = GetUtcNow(),
StoredResult = result
};
@ -303,7 +333,7 @@ public sealed class InMemoryMcpTaskStore : IMcpTaskStore, IDisposable
{
Status = status,
StatusMessage = statusMessage,
LastUpdatedAt = DateTimeOffset.UtcNow,
LastUpdatedAt = GetUtcNow(),
};
if (_tasks.TryUpdate(taskId, updatedEntry, entry))
@ -321,32 +351,22 @@ public sealed class InMemoryMcpTaskStore : IMcpTaskStore, IDisposable
string? sessionId = null,
CancellationToken cancellationToken = default)
{
// Parse cursor: format is "CreatedAt|TaskId" for keyset pagination
(DateTimeOffset, string)? parsedCursor = null;
if (cursor != null)
{
var parts = cursor.Split('|');
if (parts.Length == 2 &&
DateTimeOffset.TryParse(parts[0], out var parsedDate))
{
parsedCursor = (parsedDate, parts[1]);
}
}
// Stream enumeration - filter by session, exclude expired, apply keyset pagination
var query = _tasks.Values
.Where(e => sessionId == null || e.SessionId == sessionId)
.Where(e => !IsExpired(e));
// Apply keyset filter if cursor provided: (CreatedAt, TaskId) > cursor
if (parsedCursor is { } parsedCursorValue)
// Apply keyset filter if cursor provided: TaskId > cursor
// UUID v7 task IDs are monotonically increasing and inherently time-ordered
if (cursor != null)
{
query = query.Where(e => (e.CreatedAt, e.TaskId).CompareTo(parsedCursorValue) > 0);
query = query.Where(e => string.CompareOrdinal(e.TaskId, cursor) > 0);
}
// Order by (CreatedAt, TaskId) for stable, deterministic pagination
// Order by TaskId for stable, deterministic pagination
// UUID v7 task IDs sort chronologically due to embedded timestamp
var page = query
.OrderBy(e => (e.CreatedAt, e.TaskId))
.OrderBy(e => e.TaskId, StringComparer.Ordinal)
.Take(_pageSize + 1) // Take one extra to check if there's a next page
.Select(e => e.ToMcpTask())
.ToList();
@ -356,7 +376,7 @@ public sealed class InMemoryMcpTaskStore : IMcpTaskStore, IDisposable
if (page.Count > _pageSize)
{
var lastItemInPage = page[_pageSize - 1]; // Last item we'll actually return
nextCursor = $"{lastItemInPage.CreatedAt:O}|{lastItemInPage.TaskId}";
nextCursor = lastItemInPage.TaskId;
page.RemoveAt(_pageSize); // Remove the extra item
}
else
@ -397,7 +417,7 @@ public sealed class InMemoryMcpTaskStore : IMcpTaskStore, IDisposable
var updatedEntry = new TaskEntry(entry)
{
Status = McpTaskStatus.Cancelled,
LastUpdatedAt = DateTimeOffset.UtcNow,
LastUpdatedAt = GetUtcNow(),
};
if (_tasks.TryUpdate(taskId, updatedEntry, entry))
@ -417,12 +437,23 @@ public sealed class InMemoryMcpTaskStore : IMcpTaskStore, IDisposable
_cleanupTimer?.Dispose();
}
private static string GenerateTaskId() => Guid.NewGuid().ToString("N");
private string GenerateTaskId() =>
IdHelpers.CreateMonotonicId(GetUtcNow());
private static bool IsTerminalStatus(McpTaskStatus status) =>
status is McpTaskStatus.Completed or McpTaskStatus.Failed or McpTaskStatus.Cancelled;
#if MCP_TEST_TIME_PROVIDER
private DateTimeOffset GetUtcNow() => _timeProvider.GetUtcNow();
#else
private static DateTimeOffset GetUtcNow() => DateTimeOffset.UtcNow;
#endif
#if MCP_TEST_TIME_PROVIDER
private bool IsExpired(TaskEntry entry)
#else
private static bool IsExpired(TaskEntry entry)
#endif
{
if (entry.TimeToLive == null)
{
@ -430,7 +461,7 @@ public sealed class InMemoryMcpTaskStore : IMcpTaskStore, IDisposable
}
var expirationTime = entry.CreatedAt + entry.TimeToLive.Value;
return DateTimeOffset.UtcNow >= expirationTime;
return GetUtcNow() >= expirationTime;
}
private void CleanupExpiredTasks(object? state)

View file

@ -5,12 +5,15 @@
<TargetFrameworks>net10.0;net9.0;net8.0;net472</TargetFrameworks>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<RootNamespace>ModelContextProtocol.Tests</RootNamespace>
<!-- https://github.com/dotnet/sdk/issues/51060 -->
<NoWarn>$(NoWarn);NU1903;NU1902</NoWarn>
<!-- Define for conditional TimeProvider support in InMemoryMcpTaskStore -->
<DefineConstants>$(DefineConstants);MCP_TEST_TIME_PROVIDER</DefineConstants>
</PropertyGroup>
<PropertyGroup Condition="'$(TargetFramework)' == 'net9.0'">
@ -27,6 +30,10 @@
<ItemGroup>
<Compile Include="..\Common\**\*.cs" />
<!-- Link InMemoryMcpTaskStore.cs for testing with TimeProvider support -->
<Compile Include="..\..\src\ModelContextProtocol.Core\Server\InMemoryMcpTaskStore.cs" Link="Server\InMemoryMcpTaskStore.cs" />
<!-- Include dependencies for the linked InMemoryMcpTaskStore.cs -->
<Compile Include="..\..\src\Common\Experimentals.cs" Link="Experimentals.cs" />
</ItemGroup>
<ItemGroup Condition="'$(TargetFramework)' == 'net472'">
@ -34,6 +41,10 @@
<Compile Include="..\..\src\Common\Polyfills\**\*.cs" />
</ItemGroup>
<ItemGroup Condition="'$(TargetFramework)' != 'net472'">
<Compile Include="..\..\src\Common\Polyfills\System\IdHelpers.cs" Link="Polyfills\IdHelpers.cs" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
@ -43,6 +54,7 @@
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" />
<PackageReference Include="Microsoft.Extensions.Logging" />
<PackageReference Include="Microsoft.Extensions.Logging.Console" />
<PackageReference Include="Microsoft.Extensions.TimeProvider.Testing" />
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="Moq" />
<PackageReference Include="OpenTelemetry" />

View file

@ -1,7 +1,9 @@
using Microsoft.Extensions.Time.Testing;
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;
using ModelContextProtocol.Tests.Utils;
using System.Text.Json;
using TestInMemoryMcpTaskStore = ModelContextProtocol.Tests.Internal.InMemoryMcpTaskStore;
namespace ModelContextProtocol.Tests.Server;
@ -1031,4 +1033,141 @@ public class InMemoryMcpTaskStoreTests : LoggedTest
// Assert
Assert.NotNull(task2);
}
[Fact]
public async Task ListTasksAsync_KeysetPaginationWorksWithIdenticalTimestamps()
{
// Arrange - Use a fake time provider to create tasks with identical timestamps
var fakeTime = new FakeTimeProvider(DateTimeOffset.UtcNow);
using var store = new TestInMemoryMcpTaskStore(
defaultTtl: null,
maxTtl: null,
pollInterval: null,
cleanupInterval: Timeout.InfiniteTimeSpan,
pageSize: 5,
maxTasks: null,
maxTasksPerSession: null,
timeProvider: fakeTime);
// Create 10 tasks - all with the EXACT same timestamp
var createdTasks = new List<McpTask>();
for (int i = 0; i < 10; i++)
{
var task = await store.CreateTaskAsync(
new McpTaskMetadata(),
new RequestId($"req-{i}"),
new JsonRpcRequest { Method = "test" },
null,
TestContext.Current.CancellationToken);
createdTasks.Add(task);
}
// Verify all tasks have the same CreatedAt timestamp
var firstTimestamp = createdTasks[0].CreatedAt;
Assert.All(createdTasks, task => Assert.Equal(firstTimestamp, task.CreatedAt));
// Act - Get first page
var result1 = await store.ListTasksAsync(cancellationToken: TestContext.Current.CancellationToken);
// Assert - First page should have 5 tasks
Assert.Equal(5, result1.Tasks.Length);
Assert.NotNull(result1.NextCursor);
// Get second page using cursor
var result2 = await store.ListTasksAsync(cursor: result1.NextCursor, cancellationToken: TestContext.Current.CancellationToken);
// Assert - Second page should have 5 tasks
Assert.Equal(5, result2.Tasks.Length);
Assert.Null(result2.NextCursor); // No more pages
// Verify no overlap between pages
var page1Ids = result1.Tasks.Select(t => t.TaskId).ToHashSet();
var page2Ids = result2.Tasks.Select(t => t.TaskId).ToHashSet();
Assert.Empty(page1Ids.Intersect(page2Ids));
// Verify we got all 10 tasks exactly once
var allReturnedIds = page1Ids.Union(page2Ids).ToHashSet();
var allCreatedIds = createdTasks.Select(t => t.TaskId).ToHashSet();
Assert.Equal(allCreatedIds, allReturnedIds);
}
[Fact]
public async Task ListTasksAsync_TasksCreatedAfterFirstPageWithSameTimestampAppearInSecondPage()
{
// Arrange - Use a fake time provider so we can control timestamps precisely
var fakeTime = new FakeTimeProvider(DateTimeOffset.UtcNow);
using var store = new TestInMemoryMcpTaskStore(
defaultTtl: null,
maxTtl: null,
pollInterval: null,
cleanupInterval: Timeout.InfiniteTimeSpan,
pageSize: 5,
maxTasks: null,
maxTasksPerSession: null,
timeProvider: fakeTime);
// Create initial 6 tasks - all with the same timestamp
// (6 so that first page has 5 and cursor points to task 5)
var initialTasks = new List<McpTask>();
for (int i = 0; i < 6; i++)
{
var task = await store.CreateTaskAsync(
new McpTaskMetadata(),
new RequestId($"req-initial-{i}"),
new JsonRpcRequest { Method = "test" },
null,
TestContext.Current.CancellationToken);
initialTasks.Add(task);
}
// Get first page - should have 5 tasks with a cursor
var result1 = await store.ListTasksAsync(cancellationToken: TestContext.Current.CancellationToken);
Assert.Equal(5, result1.Tasks.Length);
Assert.NotNull(result1.NextCursor);
// Now create 5 more tasks AFTER we got the first page cursor
// These tasks have the SAME timestamp as the cursor (time hasn't moved)
// Due to monotonic UUID v7 with counter, they should sort AFTER the cursor
var laterTasks = new List<McpTask>();
for (int i = 0; i < 5; i++)
{
var task = await store.CreateTaskAsync(
new McpTaskMetadata(),
new RequestId($"req-later-{i}"),
new JsonRpcRequest { Method = "test" },
null,
TestContext.Current.CancellationToken);
laterTasks.Add(task);
}
// Verify all tasks have the same timestamp
var allTasks = initialTasks.Concat(laterTasks).ToList();
var firstTimestamp = allTasks[0].CreatedAt;
Assert.All(allTasks, task => Assert.Equal(firstTimestamp, task.CreatedAt));
// Get ALL remaining pages
var allSubsequentTasks = new List<McpTask>();
string? cursor = result1.NextCursor;
while (cursor != null)
{
var result = await store.ListTasksAsync(cursor: cursor, cancellationToken: TestContext.Current.CancellationToken);
allSubsequentTasks.AddRange(result.Tasks);
cursor = result.NextCursor;
}
// Verify no overlap between first page and subsequent
var page1Ids = result1.Tasks.Select(t => t.TaskId).ToHashSet();
var subsequentIds = allSubsequentTasks.Select(t => t.TaskId).ToHashSet();
Assert.Empty(page1Ids.Intersect(subsequentIds));
// Verify we got all tasks
var allReturnedIds = page1Ids.Union(subsequentIds).ToHashSet();
var allCreatedIds = allTasks.Select(t => t.TaskId).ToHashSet();
Assert.Equal(allCreatedIds, allReturnedIds);
// Most importantly: verify ALL the later tasks (created after first page) are surfaced
// in the subsequent pages
var laterTaskIds = laterTasks.Select(t => t.TaskId).ToHashSet();
Assert.Superset(laterTaskIds, subsequentIds);
}
}