Skip to content
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
0e085be
Initial plan
Copilot Jan 30, 2026
f32e1e1
Fix sporadic test failure by using monotonically increasing task IDs
Copilot Jan 30, 2026
b699d69
Fix keyset pagination by sorting before applying cursor filter
Copilot Jan 30, 2026
fba8ccf
Refactor keyset pagination code styling per review feedback
Copilot Jan 30, 2026
0fd7769
Add TimeProvider support and test for identical timestamp handling
Copilot Jan 30, 2026
b8fa0d8
Revert TimeProvider NuGet dependency, use conditional compilation for…
Copilot Jan 30, 2026
1213ce8
Add comment explaining CS0436 suppression for linked InMemoryMcpTaskS…
Copilot Jan 30, 2026
a80c8b4
Use conditional namespace to avoid CS0436 type conflicts
Copilot Jan 30, 2026
32dbb40
Use monotonic UUID v7 for task IDs with polyfill for older targets
Copilot Jan 30, 2026
fdd7e85
Move UUID v7 polyfill to Common/Polyfills/System/GuidPolyfills.cs
Copilot Jan 30, 2026
4053b29
Revert ListTasksAsync changes, use monotonic GUID with TimeProvider s…
Copilot Jan 30, 2026
a637ea2
Move UUID v7 generation logic back to GuidPolyfills class
Copilot Jan 30, 2026
8ef5371
Simplify pagination cursor to use only TaskId since UUID v7 is monoto…
Copilot Jan 30, 2026
ef86c4d
Rename polyfill to CreateVersion7(DateTimeOffset) to match .NET 9 API
Copilot Jan 30, 2026
676aecb
Revert to CreateMonotonicUuid name and document why CreateVersion7 ca…
Copilot Jan 30, 2026
0877589
Refactor GuidPolyfills to GuidHelpers with simplified monotonic counter
Copilot Jan 30, 2026
7ea4064
Simplify GUID creation using unsafe pointer manipulation
Copilot Jan 30, 2026
e17be78
Fix test project duplicate compile for GuidHelpers.cs on net472
Copilot Jan 30, 2026
a9f8a4d
Scope unsafe block to only the pointer manipulation lines
Copilot Jan 30, 2026
446477d
Clarify comment that this is UUIDv7-like format, not RFC-compliant
Copilot Jan 30, 2026
0854883
Move AllowUnsafeBlocks to specific project csproj files
Copilot Jan 30, 2026
fe42850
Use two longs (ticks + counter) for monotonic ID generation instead o…
Copilot Jan 30, 2026
5dc6a21
Rename GuidHelpers to IdHelpers since it no longer generates GUIDs
Copilot Jan 30, 2026
14d0d82
Update src/ModelContextProtocol.Core/ModelContextProtocol.Core.csproj
stephentoub Jan 30, 2026
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
83 changes: 83 additions & 0 deletions src/Common/Polyfills/System/GuidPolyfills.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
namespace System;

/// <summary>
/// Provides polyfills for GUID generation methods not available in older .NET versions.
/// </summary>
Comment thread
stephentoub marked this conversation as resolved.
Outdated
internal static class GuidPolyfills
{
private static long s_lastTimestamp;
private static long s_counter;
private static readonly object s_lock = new();

/// <summary>
/// Creates a monotonically increasing GUID using UUID v7 format with the specified timestamp.
/// Uses a counter for intra-millisecond ordering to ensure strict monotonicity.
/// </summary>
/// <param name="timestamp">The Unix timestamp in milliseconds to embed in the GUID.</param>
/// <returns>A new monotonically increasing GUID.</returns>
public static Guid CreateMonotonicUuid(long timestamp)
{
// UUID v7 format (RFC 9562):
// - 48 bits: Unix timestamp in milliseconds (big-endian)
// - 4 bits: version (0111 = 7)
// - 12 bits: counter/sequence (for intra-millisecond ordering)
// - 2 bits: variant (10)
// - 62 bits: random

long counter;

lock (s_lock)
{
if (timestamp == s_lastTimestamp)
{
// Same millisecond - increment counter
s_counter++;
}
else
{
// New millisecond - reset counter
Comment thread
stephentoub marked this conversation as resolved.
Outdated
s_lastTimestamp = timestamp;
s_counter = 0;
}

counter = s_counter;
}

byte[] bytes = new byte[16];
Comment thread
stephentoub marked this conversation as resolved.
Outdated

// Fill lower random bits (last 8 bytes) with random data
#if NETSTANDARD2_0
using (var rng = Security.Cryptography.RandomNumberGenerator.Create())
{
rng.GetBytes(bytes, 8, 8);
}
#else
Security.Cryptography.RandomNumberGenerator.Fill(bytes.AsSpan(8, 8));
Comment thread
stephentoub marked this conversation as resolved.
Outdated
#endif

// Set timestamp (48 bits, big-endian) in first 6 bytes
bytes[0] = (byte)(timestamp >> 40);
bytes[1] = (byte)(timestamp >> 32);
bytes[2] = (byte)(timestamp >> 24);
bytes[3] = (byte)(timestamp >> 16);
bytes[4] = (byte)(timestamp >> 8);
bytes[5] = (byte)timestamp;

// Set version 7 (0111) in high nibble of byte 6, and high 4 bits of counter in low nibble
bytes[6] = (byte)(0x70 | ((counter >> 8) & 0x0F));

// Set remaining 8 bits of counter in byte 7
bytes[7] = (byte)(counter & 0xFF);
Comment thread
stephentoub marked this conversation as resolved.
Outdated

// Set variant (10) in high 2 bits of byte 8
bytes[8] = (byte)((bytes[8] & 0x3F) | 0x80);

// Convert from big-endian byte array to Guid
return new Guid(
(int)(bytes[0] << 24 | bytes[1] << 16 | bytes[2] << 8 | bytes[3]),
(short)(bytes[4] << 8 | bytes[5]),
(short)(bytes[6] << 8 | bytes[7]),
bytes[8], bytes[9], bytes[10], bytes[11],
bytes[12], bytes[13], bytes[14], bytes[15]);
}
}
53 changes: 47 additions & 6 deletions src/ModelContextProtocol.Core/Server/InMemoryMcpTaskStore.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,11 @@
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.
Expand Down Expand Up @@ -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
Comment thread
eiriktsarpalis marked this conversation as resolved.

/// <summary>
/// Initializes a new instance of the <see cref="InMemoryMcpTaskStore"/> class.
Expand Down Expand Up @@ -120,6 +127,9 @@ public InMemoryMcpTaskStore(
_pageSize = pageSize;
_maxTasks = maxTasks;
_maxTasksPerSession = maxTasksPerSession;
#if MCP_TEST_TIME_PROVIDER
_timeProvider = TimeProvider.System;
#endif

cleanupInterval ??= TimeSpan.FromMinutes(1);
if (cleanupInterval.Value != Timeout.InfiniteTimeSpan)
Expand All @@ -128,6 +138,26 @@ public InMemoryMcpTaskStore(
}
}

#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,
Expand Down Expand Up @@ -155,7 +185,7 @@ public Task<McpTask> CreateTaskAsync(
}

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;
Expand Down Expand Up @@ -242,7 +272,7 @@ public Task<McpTask> StoreTaskResultAsync(
var updatedEntry = new TaskEntry(entry)
{
Status = status,
LastUpdatedAt = DateTimeOffset.UtcNow,
LastUpdatedAt = GetUtcNow(),
StoredResult = result
};

Expand Down Expand Up @@ -303,7 +333,7 @@ public Task<McpTask> UpdateTaskStatusAsync(
{
Status = status,
StatusMessage = statusMessage,
LastUpdatedAt = DateTimeOffset.UtcNow,
LastUpdatedAt = GetUtcNow(),
};

if (_tasks.TryUpdate(taskId, updatedEntry, entry))
Expand Down Expand Up @@ -397,7 +427,7 @@ public Task<McpTask> CancelTaskAsync(string taskId, string? sessionId = null, Ca
var updatedEntry = new TaskEntry(entry)
{
Status = McpTaskStatus.Cancelled,
LastUpdatedAt = DateTimeOffset.UtcNow,
LastUpdatedAt = GetUtcNow(),
};

if (_tasks.TryUpdate(taskId, updatedEntry, entry))
Expand All @@ -417,20 +447,31 @@ public void Dispose()
_cleanupTimer?.Dispose();
}

private static string GenerateTaskId() => Guid.NewGuid().ToString("N");
private string GenerateTaskId() =>
GuidPolyfills.CreateMonotonicUuid(GetUtcNow().ToUnixTimeMilliseconds()).ToString("N");
Comment thread
stephentoub marked this conversation as resolved.
Outdated

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)
{
return false; // Unlimited lifetime
}

var expirationTime = entry.CreatedAt + entry.TimeToLive.Value;
return DateTimeOffset.UtcNow >= expirationTime;
return GetUtcNow() >= expirationTime;
}

private void CleanupExpiredTasks(object? state)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
<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'">
Expand All @@ -27,6 +29,11 @@

<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" />
<Compile Include="..\..\src\Common\Polyfills\System\GuidPolyfills.cs" Link="Polyfills\GuidPolyfills.cs" />
</ItemGroup>

<ItemGroup Condition="'$(TargetFramework)' == 'net472'">
Expand All @@ -43,6 +50,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" />
Expand Down
139 changes: 139 additions & 0 deletions tests/ModelContextProtocol.Tests/Server/InMemoryMcpTaskStoreTests.cs
Original file line number Diff line number Diff line change
@@ -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;

Expand Down Expand Up @@ -1031,4 +1033,141 @@ public async Task CreateTaskAsync_MaxTasksPerSession_ExcludesExpiredTasks()
// 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);
}
}