From 0e085bef8b22f12002e4f9290b400e6a45d0bac1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 30 Jan 2026 08:21:28 +0000 Subject: [PATCH 01/24] Initial plan From f32e1e1b692bbff58fa7b1666446f071f11d553c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 30 Jan 2026 08:33:00 +0000 Subject: [PATCH 02/24] Fix sporadic test failure by using monotonically increasing task IDs Co-authored-by: eiriktsarpalis <2813363+eiriktsarpalis@users.noreply.github.com> --- .../Server/InMemoryMcpTaskStore.cs | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/src/ModelContextProtocol.Core/Server/InMemoryMcpTaskStore.cs b/src/ModelContextProtocol.Core/Server/InMemoryMcpTaskStore.cs index c24b78961..36d78e2b1 100644 --- a/src/ModelContextProtocol.Core/Server/InMemoryMcpTaskStore.cs +++ b/src/ModelContextProtocol.Core/Server/InMemoryMcpTaskStore.cs @@ -27,6 +27,10 @@ namespace ModelContextProtocol; [Experimental(Experimentals.Tasks_DiagnosticId, UrlFormat = Experimentals.Tasks_Url)] public sealed class InMemoryMcpTaskStore : IMcpTaskStore, IDisposable { + // Counter used for generating monotonically increasing task IDs, ensuring chronological ordering + // even when tasks are created within the same millisecond. + private static long s_taskIdCounter; + private readonly ConcurrentDictionary _tasks = new(); private readonly TimeSpan? _defaultTtl; private readonly TimeSpan? _maxTtl; @@ -179,7 +183,7 @@ public Task CreateTaskAsync( if (!_tasks.TryAdd(taskId, entry)) { - // This should be extremely rare with GUID-based IDs + // Should never happen with counter-based IDs since each counter value is unique throw new InvalidOperationException($"Task ID collision: {taskId}"); } @@ -417,7 +421,15 @@ public void Dispose() _cleanupTimer?.Dispose(); } - private static string GenerateTaskId() => Guid.NewGuid().ToString("N"); + private string GenerateTaskId() + { + // Use Interlocked.Increment to generate a monotonically increasing counter. + // This ensures task IDs maintain chronological ordering for keyset pagination, + // even when multiple tasks are created within the same millisecond. + // Format: {counter:D20}-{uniqueSuffix} where D20 ensures lexicographic sorting. + long counter = Interlocked.Increment(ref s_taskIdCounter); + return $"{counter:D20}-{Guid.NewGuid():N}"; + } private static bool IsTerminalStatus(McpTaskStatus status) => status is McpTaskStatus.Completed or McpTaskStatus.Failed or McpTaskStatus.Cancelled; From b699d69f1a362bc69757637e2ae880254a94f747 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 30 Jan 2026 08:44:09 +0000 Subject: [PATCH 03/24] Fix keyset pagination by sorting before applying cursor filter Co-authored-by: eiriktsarpalis <2813363+eiriktsarpalis@users.noreply.github.com> --- .../Server/InMemoryMcpTaskStore.cs | 34 +++++++------------ 1 file changed, 12 insertions(+), 22 deletions(-) diff --git a/src/ModelContextProtocol.Core/Server/InMemoryMcpTaskStore.cs b/src/ModelContextProtocol.Core/Server/InMemoryMcpTaskStore.cs index 36d78e2b1..babfff249 100644 --- a/src/ModelContextProtocol.Core/Server/InMemoryMcpTaskStore.cs +++ b/src/ModelContextProtocol.Core/Server/InMemoryMcpTaskStore.cs @@ -27,10 +27,6 @@ namespace ModelContextProtocol; [Experimental(Experimentals.Tasks_DiagnosticId, UrlFormat = Experimentals.Tasks_Url)] public sealed class InMemoryMcpTaskStore : IMcpTaskStore, IDisposable { - // Counter used for generating monotonically increasing task IDs, ensuring chronological ordering - // even when tasks are created within the same millisecond. - private static long s_taskIdCounter; - private readonly ConcurrentDictionary _tasks = new(); private readonly TimeSpan? _defaultTtl; private readonly TimeSpan? _maxTtl; @@ -183,7 +179,7 @@ public Task CreateTaskAsync( if (!_tasks.TryAdd(taskId, entry)) { - // Should never happen with counter-based IDs since each counter value is unique + // This should be extremely rare with GUID-based IDs throw new InvalidOperationException($"Task ID collision: {taskId}"); } @@ -337,20 +333,22 @@ public Task ListTasksAsync( } } - // Stream enumeration - filter by session, exclude expired, apply keyset pagination + // Stream enumeration - filter by session, exclude expired var query = _tasks.Values .Where(e => sessionId == null || e.SessionId == sessionId) .Where(e => !IsExpired(e)); + // Order by (CreatedAt, TaskId) for stable, deterministic pagination + // Must sort BEFORE applying keyset filter to ensure consistent comparison + var orderedQuery = query.OrderBy(e => (e.CreatedAt, e.TaskId)); + // Apply keyset filter if cursor provided: (CreatedAt, TaskId) > cursor - if (parsedCursor is { } parsedCursorValue) - { - query = query.Where(e => (e.CreatedAt, e.TaskId).CompareTo(parsedCursorValue) > 0); - } + // This runs on sorted data, so we skip items until we pass the cursor position + IEnumerable filteredQuery = parsedCursor is { } parsedCursorValue + ? orderedQuery.SkipWhile(e => (e.CreatedAt, e.TaskId).CompareTo(parsedCursorValue) <= 0) + : orderedQuery; - // Order by (CreatedAt, TaskId) for stable, deterministic pagination - var page = query - .OrderBy(e => (e.CreatedAt, e.TaskId)) + var page = filteredQuery .Take(_pageSize + 1) // Take one extra to check if there's a next page .Select(e => e.ToMcpTask()) .ToList(); @@ -421,15 +419,7 @@ public void Dispose() _cleanupTimer?.Dispose(); } - private string GenerateTaskId() - { - // Use Interlocked.Increment to generate a monotonically increasing counter. - // This ensures task IDs maintain chronological ordering for keyset pagination, - // even when multiple tasks are created within the same millisecond. - // Format: {counter:D20}-{uniqueSuffix} where D20 ensures lexicographic sorting. - long counter = Interlocked.Increment(ref s_taskIdCounter); - return $"{counter:D20}-{Guid.NewGuid():N}"; - } + private static string GenerateTaskId() => Guid.NewGuid().ToString("N"); private static bool IsTerminalStatus(McpTaskStatus status) => status is McpTaskStatus.Completed or McpTaskStatus.Failed or McpTaskStatus.Cancelled; From fba8ccf9bdb73403bfccddee65c45abb247a5466 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 30 Jan 2026 08:53:51 +0000 Subject: [PATCH 04/24] Refactor keyset pagination code styling per review feedback Co-authored-by: eiriktsarpalis <2813363+eiriktsarpalis@users.noreply.github.com> --- .../Server/InMemoryMcpTaskStore.cs | 21 +++++++++---------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/src/ModelContextProtocol.Core/Server/InMemoryMcpTaskStore.cs b/src/ModelContextProtocol.Core/Server/InMemoryMcpTaskStore.cs index babfff249..f3c67ed29 100644 --- a/src/ModelContextProtocol.Core/Server/InMemoryMcpTaskStore.cs +++ b/src/ModelContextProtocol.Core/Server/InMemoryMcpTaskStore.cs @@ -333,22 +333,21 @@ public Task ListTasksAsync( } } - // Stream enumeration - filter by session, exclude expired - var query = _tasks.Values - .Where(e => sessionId == null || e.SessionId == sessionId) - .Where(e => !IsExpired(e)); - - // Order by (CreatedAt, TaskId) for stable, deterministic pagination + // Stream enumeration - filter by session, exclude expired, order by (CreatedAt, TaskId) for stable pagination // Must sort BEFORE applying keyset filter to ensure consistent comparison - var orderedQuery = query.OrderBy(e => (e.CreatedAt, e.TaskId)); + IEnumerable query = _tasks.Values + .Where(e => sessionId == null || e.SessionId == sessionId) + .Where(e => !IsExpired(e)) + .OrderBy(e => (e.CreatedAt, e.TaskId)); // Apply keyset filter if cursor provided: (CreatedAt, TaskId) > cursor // This runs on sorted data, so we skip items until we pass the cursor position - IEnumerable filteredQuery = parsedCursor is { } parsedCursorValue - ? orderedQuery.SkipWhile(e => (e.CreatedAt, e.TaskId).CompareTo(parsedCursorValue) <= 0) - : orderedQuery; + if (parsedCursor is { } parsedCursorValue) + { + query = query.SkipWhile(e => (e.CreatedAt, e.TaskId).CompareTo(parsedCursorValue) <= 0); + } - var page = filteredQuery + var page = query .Take(_pageSize + 1) // Take one extra to check if there's a next page .Select(e => e.ToMcpTask()) .ToList(); From 0fd7769bcbfa2a2ba1ee4c1bb74d7cca36f7f098 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 30 Jan 2026 09:01:34 +0000 Subject: [PATCH 05/24] Add TimeProvider support and test for identical timestamp handling Co-authored-by: eiriktsarpalis <2813363+eiriktsarpalis@users.noreply.github.com> --- Directory.Packages.props | 1 + .../ModelContextProtocol.Core.csproj | 1 + .../Server/InMemoryMcpTaskStore.cs | 21 +++++--- .../ModelContextProtocol.Tests.csproj | 1 + .../Server/InMemoryMcpTaskStoreTests.cs | 50 +++++++++++++++++++ 5 files changed, 67 insertions(+), 7 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 42ecc18ac..e0b93965b 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -26,6 +26,7 @@ + diff --git a/src/ModelContextProtocol.Core/ModelContextProtocol.Core.csproj b/src/ModelContextProtocol.Core/ModelContextProtocol.Core.csproj index 63b7bbd45..e2be398b4 100644 --- a/src/ModelContextProtocol.Core/ModelContextProtocol.Core.csproj +++ b/src/ModelContextProtocol.Core/ModelContextProtocol.Core.csproj @@ -33,6 +33,7 @@ + diff --git a/src/ModelContextProtocol.Core/Server/InMemoryMcpTaskStore.cs b/src/ModelContextProtocol.Core/Server/InMemoryMcpTaskStore.cs index f3c67ed29..a437b1e55 100644 --- a/src/ModelContextProtocol.Core/Server/InMemoryMcpTaskStore.cs +++ b/src/ModelContextProtocol.Core/Server/InMemoryMcpTaskStore.cs @@ -35,6 +35,7 @@ public sealed class InMemoryMcpTaskStore : IMcpTaskStore, IDisposable private readonly int _pageSize; private readonly int? _maxTasks; private readonly int? _maxTasksPerSession; + private readonly TimeProvider _timeProvider; /// /// Initializes a new instance of the class. @@ -65,6 +66,10 @@ public sealed class InMemoryMcpTaskStore : IMcpTaskStore, IDisposable /// Maximum number of tasks allowed per session. Null means unlimited. /// When the limit is reached for a session, will throw . /// + /// + /// Time provider for getting the current time. Defaults to . + /// This parameter is primarily useful for testing scenarios where you need to control time. + /// public InMemoryMcpTaskStore( TimeSpan? defaultTtl = null, TimeSpan? maxTtl = null, @@ -72,7 +77,8 @@ public InMemoryMcpTaskStore( TimeSpan? cleanupInterval = null, int pageSize = 100, int? maxTasks = null, - int? maxTasksPerSession = null) + int? maxTasksPerSession = null, + TimeProvider? timeProvider = null) { if (defaultTtl.HasValue && maxTtl.HasValue && defaultTtl.Value > maxTtl.Value) { @@ -120,6 +126,7 @@ public InMemoryMcpTaskStore( _pageSize = pageSize; _maxTasks = maxTasks; _maxTasksPerSession = maxTasksPerSession; + _timeProvider = timeProvider ?? TimeProvider.System; cleanupInterval ??= TimeSpan.FromMinutes(1); if (cleanupInterval.Value != Timeout.InfiniteTimeSpan) @@ -155,7 +162,7 @@ public Task CreateTaskAsync( } var taskId = GenerateTaskId(); - var now = DateTimeOffset.UtcNow; + var now = _timeProvider.GetUtcNow(); // Determine TTL: use requested, fall back to default, respect max limit var ttl = taskParams.TimeToLive ?? _defaultTtl; @@ -242,7 +249,7 @@ public Task StoreTaskResultAsync( var updatedEntry = new TaskEntry(entry) { Status = status, - LastUpdatedAt = DateTimeOffset.UtcNow, + LastUpdatedAt = _timeProvider.GetUtcNow(), StoredResult = result }; @@ -303,7 +310,7 @@ public Task UpdateTaskStatusAsync( { Status = status, StatusMessage = statusMessage, - LastUpdatedAt = DateTimeOffset.UtcNow, + LastUpdatedAt = _timeProvider.GetUtcNow(), }; if (_tasks.TryUpdate(taskId, updatedEntry, entry)) @@ -398,7 +405,7 @@ public Task CancelTaskAsync(string taskId, string? sessionId = null, Ca var updatedEntry = new TaskEntry(entry) { Status = McpTaskStatus.Cancelled, - LastUpdatedAt = DateTimeOffset.UtcNow, + LastUpdatedAt = _timeProvider.GetUtcNow(), }; if (_tasks.TryUpdate(taskId, updatedEntry, entry)) @@ -423,7 +430,7 @@ public void Dispose() private static bool IsTerminalStatus(McpTaskStatus status) => status is McpTaskStatus.Completed or McpTaskStatus.Failed or McpTaskStatus.Cancelled; - private static bool IsExpired(TaskEntry entry) + private bool IsExpired(TaskEntry entry) { if (entry.TimeToLive == null) { @@ -431,7 +438,7 @@ private static bool IsExpired(TaskEntry entry) } var expirationTime = entry.CreatedAt + entry.TimeToLive.Value; - return DateTimeOffset.UtcNow >= expirationTime; + return _timeProvider.GetUtcNow() >= expirationTime; } private void CleanupExpiredTasks(object? state) diff --git a/tests/ModelContextProtocol.Tests/ModelContextProtocol.Tests.csproj b/tests/ModelContextProtocol.Tests/ModelContextProtocol.Tests.csproj index e0fb3d1fa..ffe0b5494 100644 --- a/tests/ModelContextProtocol.Tests/ModelContextProtocol.Tests.csproj +++ b/tests/ModelContextProtocol.Tests/ModelContextProtocol.Tests.csproj @@ -43,6 +43,7 @@ + diff --git a/tests/ModelContextProtocol.Tests/Server/InMemoryMcpTaskStoreTests.cs b/tests/ModelContextProtocol.Tests/Server/InMemoryMcpTaskStoreTests.cs index fc858ee1e..574995851 100644 --- a/tests/ModelContextProtocol.Tests/Server/InMemoryMcpTaskStoreTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/InMemoryMcpTaskStoreTests.cs @@ -1,3 +1,4 @@ +using Microsoft.Extensions.Time.Testing; using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; using ModelContextProtocol.Tests.Utils; @@ -1031,4 +1032,53 @@ 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 InMemoryMcpTaskStore(pageSize: 5, timeProvider: fakeTime); + + // Create 10 tasks - all with the EXACT same timestamp + var createdTasks = new List(); + 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); + } } From b8fa0d8857f9fc2cd870635d6f1464d7f854d363 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 30 Jan 2026 09:38:16 +0000 Subject: [PATCH 06/24] Revert TimeProvider NuGet dependency, use conditional compilation for test-only TimeProvider support Co-authored-by: eiriktsarpalis <2813363+eiriktsarpalis@users.noreply.github.com> --- Directory.Packages.props | 1 - .../ModelContextProtocol.Core.csproj | 1 - .../Server/InMemoryMcpTaskStore.cs | 53 ++++++++++++++----- .../ModelContextProtocol.Tests.csproj | 8 ++- .../Server/InMemoryMcpTaskStoreTests.cs | 10 +++- 5 files changed, 57 insertions(+), 16 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index e0b93965b..42ecc18ac 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -26,7 +26,6 @@ - diff --git a/src/ModelContextProtocol.Core/ModelContextProtocol.Core.csproj b/src/ModelContextProtocol.Core/ModelContextProtocol.Core.csproj index e2be398b4..63b7bbd45 100644 --- a/src/ModelContextProtocol.Core/ModelContextProtocol.Core.csproj +++ b/src/ModelContextProtocol.Core/ModelContextProtocol.Core.csproj @@ -33,7 +33,6 @@ - diff --git a/src/ModelContextProtocol.Core/Server/InMemoryMcpTaskStore.cs b/src/ModelContextProtocol.Core/Server/InMemoryMcpTaskStore.cs index a437b1e55..c6a84531a 100644 --- a/src/ModelContextProtocol.Core/Server/InMemoryMcpTaskStore.cs +++ b/src/ModelContextProtocol.Core/Server/InMemoryMcpTaskStore.cs @@ -35,7 +35,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 /// /// Initializes a new instance of the class. @@ -66,10 +68,6 @@ public sealed class InMemoryMcpTaskStore : IMcpTaskStore, IDisposable /// Maximum number of tasks allowed per session. Null means unlimited. /// When the limit is reached for a session, will throw . /// - /// - /// Time provider for getting the current time. Defaults to . - /// This parameter is primarily useful for testing scenarios where you need to control time. - /// public InMemoryMcpTaskStore( TimeSpan? defaultTtl = null, TimeSpan? maxTtl = null, @@ -77,8 +75,7 @@ public InMemoryMcpTaskStore( TimeSpan? cleanupInterval = null, int pageSize = 100, int? maxTasks = null, - int? maxTasksPerSession = null, - TimeProvider? timeProvider = null) + int? maxTasksPerSession = null) { if (defaultTtl.HasValue && maxTtl.HasValue && defaultTtl.Value > maxTtl.Value) { @@ -126,7 +123,9 @@ public InMemoryMcpTaskStore( _pageSize = pageSize; _maxTasks = maxTasks; _maxTasksPerSession = maxTasksPerSession; - _timeProvider = timeProvider ?? TimeProvider.System; +#if MCP_TEST_TIME_PROVIDER + _timeProvider = TimeProvider.System; +#endif cleanupInterval ??= TimeSpan.FromMinutes(1); if (cleanupInterval.Value != Timeout.InfiniteTimeSpan) @@ -135,6 +134,26 @@ public InMemoryMcpTaskStore( } } +#if MCP_TEST_TIME_PROVIDER + /// + /// Initializes a new instance of the class with a custom time provider. + /// This constructor is only available for testing purposes. + /// + 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 + /// public Task CreateTaskAsync( McpTaskMetadata taskParams, @@ -162,7 +181,7 @@ public Task CreateTaskAsync( } var taskId = GenerateTaskId(); - var now = _timeProvider.GetUtcNow(); + var now = GetUtcNow(); // Determine TTL: use requested, fall back to default, respect max limit var ttl = taskParams.TimeToLive ?? _defaultTtl; @@ -249,7 +268,7 @@ public Task StoreTaskResultAsync( var updatedEntry = new TaskEntry(entry) { Status = status, - LastUpdatedAt = _timeProvider.GetUtcNow(), + LastUpdatedAt = GetUtcNow(), StoredResult = result }; @@ -310,7 +329,7 @@ public Task UpdateTaskStatusAsync( { Status = status, StatusMessage = statusMessage, - LastUpdatedAt = _timeProvider.GetUtcNow(), + LastUpdatedAt = GetUtcNow(), }; if (_tasks.TryUpdate(taskId, updatedEntry, entry)) @@ -405,7 +424,7 @@ public Task CancelTaskAsync(string taskId, string? sessionId = null, Ca var updatedEntry = new TaskEntry(entry) { Status = McpTaskStatus.Cancelled, - LastUpdatedAt = _timeProvider.GetUtcNow(), + LastUpdatedAt = GetUtcNow(), }; if (_tasks.TryUpdate(taskId, updatedEntry, entry)) @@ -430,7 +449,17 @@ public void Dispose() 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) { @@ -438,7 +467,7 @@ private bool IsExpired(TaskEntry entry) } var expirationTime = entry.CreatedAt + entry.TimeToLive.Value; - return _timeProvider.GetUtcNow() >= expirationTime; + return GetUtcNow() >= expirationTime; } private void CleanupExpiredTasks(object? state) diff --git a/tests/ModelContextProtocol.Tests/ModelContextProtocol.Tests.csproj b/tests/ModelContextProtocol.Tests/ModelContextProtocol.Tests.csproj index ffe0b5494..3792e300b 100644 --- a/tests/ModelContextProtocol.Tests/ModelContextProtocol.Tests.csproj +++ b/tests/ModelContextProtocol.Tests/ModelContextProtocol.Tests.csproj @@ -10,7 +10,9 @@ true ModelContextProtocol.Tests - $(NoWarn);NU1903;NU1902 + $(NoWarn);NU1903;NU1902;CS0436 + + $(DefineConstants);MCP_TEST_TIME_PROVIDER @@ -27,6 +29,10 @@ + + + + diff --git a/tests/ModelContextProtocol.Tests/Server/InMemoryMcpTaskStoreTests.cs b/tests/ModelContextProtocol.Tests/Server/InMemoryMcpTaskStoreTests.cs index 574995851..157aeb06f 100644 --- a/tests/ModelContextProtocol.Tests/Server/InMemoryMcpTaskStoreTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/InMemoryMcpTaskStoreTests.cs @@ -1038,7 +1038,15 @@ 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 InMemoryMcpTaskStore(pageSize: 5, timeProvider: fakeTime); + using var store = new InMemoryMcpTaskStore( + 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(); From 1213ce85bbf30dac3cc25cda2971b64d2b7c7504 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 30 Jan 2026 10:08:54 +0000 Subject: [PATCH 07/24] Add comment explaining CS0436 suppression for linked InMemoryMcpTaskStore Co-authored-by: eiriktsarpalis <2813363+eiriktsarpalis@users.noreply.github.com> --- .../ModelContextProtocol.Tests.csproj | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/ModelContextProtocol.Tests/ModelContextProtocol.Tests.csproj b/tests/ModelContextProtocol.Tests/ModelContextProtocol.Tests.csproj index 3792e300b..45d38d721 100644 --- a/tests/ModelContextProtocol.Tests/ModelContextProtocol.Tests.csproj +++ b/tests/ModelContextProtocol.Tests/ModelContextProtocol.Tests.csproj @@ -10,6 +10,8 @@ true ModelContextProtocol.Tests + $(NoWarn);NU1903;NU1902;CS0436 $(DefineConstants);MCP_TEST_TIME_PROVIDER From a80c8b4c23ec70ad8084ebce4ff12bee645581e1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 30 Jan 2026 11:38:24 +0000 Subject: [PATCH 08/24] Use conditional namespace to avoid CS0436 type conflicts Co-authored-by: eiriktsarpalis <2813363+eiriktsarpalis@users.noreply.github.com> --- src/ModelContextProtocol.Core/Server/InMemoryMcpTaskStore.cs | 4 ++++ .../ModelContextProtocol.Tests.csproj | 4 +--- .../Server/InMemoryMcpTaskStoreTests.cs | 3 ++- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/ModelContextProtocol.Core/Server/InMemoryMcpTaskStore.cs b/src/ModelContextProtocol.Core/Server/InMemoryMcpTaskStore.cs index c6a84531a..0e95f19b3 100644 --- a/src/ModelContextProtocol.Core/Server/InMemoryMcpTaskStore.cs +++ b/src/ModelContextProtocol.Core/Server/InMemoryMcpTaskStore.cs @@ -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 /// /// Provides an in-memory implementation of for development and testing. diff --git a/tests/ModelContextProtocol.Tests/ModelContextProtocol.Tests.csproj b/tests/ModelContextProtocol.Tests/ModelContextProtocol.Tests.csproj index 45d38d721..b8d4f3b23 100644 --- a/tests/ModelContextProtocol.Tests/ModelContextProtocol.Tests.csproj +++ b/tests/ModelContextProtocol.Tests/ModelContextProtocol.Tests.csproj @@ -10,9 +10,7 @@ true ModelContextProtocol.Tests - - $(NoWarn);NU1903;NU1902;CS0436 + $(NoWarn);NU1903;NU1902 $(DefineConstants);MCP_TEST_TIME_PROVIDER diff --git a/tests/ModelContextProtocol.Tests/Server/InMemoryMcpTaskStoreTests.cs b/tests/ModelContextProtocol.Tests/Server/InMemoryMcpTaskStoreTests.cs index 157aeb06f..7a65b8afd 100644 --- a/tests/ModelContextProtocol.Tests/Server/InMemoryMcpTaskStoreTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/InMemoryMcpTaskStoreTests.cs @@ -3,6 +3,7 @@ using ModelContextProtocol.Server; using ModelContextProtocol.Tests.Utils; using System.Text.Json; +using TestInMemoryMcpTaskStore = ModelContextProtocol.Tests.Internal.InMemoryMcpTaskStore; namespace ModelContextProtocol.Tests.Server; @@ -1038,7 +1039,7 @@ 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 InMemoryMcpTaskStore( + using var store = new TestInMemoryMcpTaskStore( defaultTtl: null, maxTtl: null, pollInterval: null, From 32dbb40573d18cdad90222a65661b39bc445eab9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 30 Jan 2026 12:48:59 +0000 Subject: [PATCH 09/24] Use monotonic UUID v7 for task IDs with polyfill for older targets Co-authored-by: eiriktsarpalis <2813363+eiriktsarpalis@users.noreply.github.com> --- .../Server/InMemoryMcpTaskStore.cs | 58 ++++++++++++++++++- 1 file changed, 57 insertions(+), 1 deletion(-) diff --git a/src/ModelContextProtocol.Core/Server/InMemoryMcpTaskStore.cs b/src/ModelContextProtocol.Core/Server/InMemoryMcpTaskStore.cs index 0e95f19b3..e67730e14 100644 --- a/src/ModelContextProtocol.Core/Server/InMemoryMcpTaskStore.cs +++ b/src/ModelContextProtocol.Core/Server/InMemoryMcpTaskStore.cs @@ -448,7 +448,63 @@ public void Dispose() _cleanupTimer?.Dispose(); } - private static string GenerateTaskId() => Guid.NewGuid().ToString("N"); +#if NET9_0_OR_GREATER + private static string GenerateTaskId() => Guid.CreateVersion7().ToString("N"); +#else + private static string GenerateTaskId() => CreateVersion7Guid().ToString("N"); + + /// + /// Polyfill for Guid.CreateVersion7() on targets earlier than .NET 9. + /// Generates a UUID v7 with embedded timestamp for monotonic ordering. + /// + private static Guid CreateVersion7Guid() + { + // UUID v7 format (RFC 9562): + // - 48 bits: Unix timestamp in milliseconds (big-endian) + // - 4 bits: version (0111 = 7) + // - 12 bits: random + // - 2 bits: variant (10) + // - 62 bits: random + + var timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + byte[] bytes = new byte[16]; + + // Fill with random data first +#if NETSTANDARD2_0 + using (var rng = System.Security.Cryptography.RandomNumberGenerator.Create()) + { + rng.GetBytes(bytes); + } +#else + System.Security.Cryptography.RandomNumberGenerator.Fill(bytes); +#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 + bytes[6] = (byte)((bytes[6] & 0x0F) | 0x70); + + // 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 + // Guid constructor expects bytes in a specific order for the first 8 bytes + // (little-endian for the first three components on Windows) + // We need to swap bytes to match the Guid's internal layout + return new Guid( + (int)(bytes[0] << 24 | bytes[1] << 16 | bytes[2] << 8 | bytes[3]), // a (big-endian to int) + (short)(bytes[4] << 8 | bytes[5]), // b (big-endian to short) + (short)(bytes[6] << 8 | bytes[7]), // c (big-endian to short) + bytes[8], bytes[9], bytes[10], bytes[11], + bytes[12], bytes[13], bytes[14], bytes[15]); + } +#endif private static bool IsTerminalStatus(McpTaskStatus status) => status is McpTaskStatus.Completed or McpTaskStatus.Failed or McpTaskStatus.Cancelled; From fdd7e85246345f3e9d94cb488647d430ab0bb3e8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 30 Jan 2026 13:25:35 +0000 Subject: [PATCH 10/24] Move UUID v7 polyfill to Common/Polyfills/System/GuidPolyfills.cs Co-authored-by: eiriktsarpalis <2813363+eiriktsarpalis@users.noreply.github.com> --- src/Common/Polyfills/System/GuidPolyfills.cs | 62 +++++++++++++++++++ .../Server/InMemoryMcpTaskStore.cs | 54 +--------------- 2 files changed, 63 insertions(+), 53 deletions(-) create mode 100644 src/Common/Polyfills/System/GuidPolyfills.cs diff --git a/src/Common/Polyfills/System/GuidPolyfills.cs b/src/Common/Polyfills/System/GuidPolyfills.cs new file mode 100644 index 000000000..2296d60d0 --- /dev/null +++ b/src/Common/Polyfills/System/GuidPolyfills.cs @@ -0,0 +1,62 @@ +#if !NET9_0_OR_GREATER +namespace System; + +/// +/// Polyfill for Guid methods not available in older .NET versions. +/// +internal static class GuidPolyfills +{ + /// + /// Creates a new Guid according to RFC 9562, following the Version 7 format. + /// This polyfill provides the functionality of Guid.CreateVersion7() for targets earlier than .NET 9. + /// + /// A new Guid with embedded timestamp for monotonic ordering. + public static Guid CreateVersion7() + { + // UUID v7 format (RFC 9562): + // - 48 bits: Unix timestamp in milliseconds (big-endian) + // - 4 bits: version (0111 = 7) + // - 12 bits: random + // - 2 bits: variant (10) + // - 62 bits: random + + var timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + byte[] bytes = new byte[16]; + + // Fill with random data first +#if NETSTANDARD2_0 + using (var rng = Security.Cryptography.RandomNumberGenerator.Create()) + { + rng.GetBytes(bytes); + } +#else + Security.Cryptography.RandomNumberGenerator.Fill(bytes); +#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 + bytes[6] = (byte)((bytes[6] & 0x0F) | 0x70); + + // 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 + // Guid constructor expects bytes in a specific order for the first 8 bytes + // (little-endian for the first three components on Windows) + // We need to swap bytes to match the Guid's internal layout + return new Guid( + (int)(bytes[0] << 24 | bytes[1] << 16 | bytes[2] << 8 | bytes[3]), // a (big-endian to int) + (short)(bytes[4] << 8 | bytes[5]), // b (big-endian to short) + (short)(bytes[6] << 8 | bytes[7]), // c (big-endian to short) + bytes[8], bytes[9], bytes[10], bytes[11], + bytes[12], bytes[13], bytes[14], bytes[15]); + } +} +#endif diff --git a/src/ModelContextProtocol.Core/Server/InMemoryMcpTaskStore.cs b/src/ModelContextProtocol.Core/Server/InMemoryMcpTaskStore.cs index e67730e14..d2515a1c4 100644 --- a/src/ModelContextProtocol.Core/Server/InMemoryMcpTaskStore.cs +++ b/src/ModelContextProtocol.Core/Server/InMemoryMcpTaskStore.cs @@ -451,59 +451,7 @@ public void Dispose() #if NET9_0_OR_GREATER private static string GenerateTaskId() => Guid.CreateVersion7().ToString("N"); #else - private static string GenerateTaskId() => CreateVersion7Guid().ToString("N"); - - /// - /// Polyfill for Guid.CreateVersion7() on targets earlier than .NET 9. - /// Generates a UUID v7 with embedded timestamp for monotonic ordering. - /// - private static Guid CreateVersion7Guid() - { - // UUID v7 format (RFC 9562): - // - 48 bits: Unix timestamp in milliseconds (big-endian) - // - 4 bits: version (0111 = 7) - // - 12 bits: random - // - 2 bits: variant (10) - // - 62 bits: random - - var timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); - byte[] bytes = new byte[16]; - - // Fill with random data first -#if NETSTANDARD2_0 - using (var rng = System.Security.Cryptography.RandomNumberGenerator.Create()) - { - rng.GetBytes(bytes); - } -#else - System.Security.Cryptography.RandomNumberGenerator.Fill(bytes); -#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 - bytes[6] = (byte)((bytes[6] & 0x0F) | 0x70); - - // 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 - // Guid constructor expects bytes in a specific order for the first 8 bytes - // (little-endian for the first three components on Windows) - // We need to swap bytes to match the Guid's internal layout - return new Guid( - (int)(bytes[0] << 24 | bytes[1] << 16 | bytes[2] << 8 | bytes[3]), // a (big-endian to int) - (short)(bytes[4] << 8 | bytes[5]), // b (big-endian to short) - (short)(bytes[6] << 8 | bytes[7]), // c (big-endian to short) - bytes[8], bytes[9], bytes[10], bytes[11], - bytes[12], bytes[13], bytes[14], bytes[15]); - } + private static string GenerateTaskId() => GuidPolyfills.CreateVersion7().ToString("N"); #endif private static bool IsTerminalStatus(McpTaskStatus status) => From 4053b29b62d343cd928db38896e5d5db36a8cfa7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 30 Jan 2026 13:44:00 +0000 Subject: [PATCH 11/24] Revert ListTasksAsync changes, use monotonic GUID with TimeProvider support, add new test Co-authored-by: eiriktsarpalis <2813363+eiriktsarpalis@users.noreply.github.com> --- src/Common/Polyfills/System/GuidPolyfills.cs | 62 ------------- .../Server/InMemoryMcpTaskStore.cs | 91 +++++++++++++++++-- .../Server/InMemoryMcpTaskStoreTests.cs | 80 ++++++++++++++++ 3 files changed, 161 insertions(+), 72 deletions(-) delete mode 100644 src/Common/Polyfills/System/GuidPolyfills.cs diff --git a/src/Common/Polyfills/System/GuidPolyfills.cs b/src/Common/Polyfills/System/GuidPolyfills.cs deleted file mode 100644 index 2296d60d0..000000000 --- a/src/Common/Polyfills/System/GuidPolyfills.cs +++ /dev/null @@ -1,62 +0,0 @@ -#if !NET9_0_OR_GREATER -namespace System; - -/// -/// Polyfill for Guid methods not available in older .NET versions. -/// -internal static class GuidPolyfills -{ - /// - /// Creates a new Guid according to RFC 9562, following the Version 7 format. - /// This polyfill provides the functionality of Guid.CreateVersion7() for targets earlier than .NET 9. - /// - /// A new Guid with embedded timestamp for monotonic ordering. - public static Guid CreateVersion7() - { - // UUID v7 format (RFC 9562): - // - 48 bits: Unix timestamp in milliseconds (big-endian) - // - 4 bits: version (0111 = 7) - // - 12 bits: random - // - 2 bits: variant (10) - // - 62 bits: random - - var timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); - byte[] bytes = new byte[16]; - - // Fill with random data first -#if NETSTANDARD2_0 - using (var rng = Security.Cryptography.RandomNumberGenerator.Create()) - { - rng.GetBytes(bytes); - } -#else - Security.Cryptography.RandomNumberGenerator.Fill(bytes); -#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 - bytes[6] = (byte)((bytes[6] & 0x0F) | 0x70); - - // 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 - // Guid constructor expects bytes in a specific order for the first 8 bytes - // (little-endian for the first three components on Windows) - // We need to swap bytes to match the Guid's internal layout - return new Guid( - (int)(bytes[0] << 24 | bytes[1] << 16 | bytes[2] << 8 | bytes[3]), // a (big-endian to int) - (short)(bytes[4] << 8 | bytes[5]), // b (big-endian to short) - (short)(bytes[6] << 8 | bytes[7]), // c (big-endian to short) - bytes[8], bytes[9], bytes[10], bytes[11], - bytes[12], bytes[13], bytes[14], bytes[15]); - } -} -#endif diff --git a/src/ModelContextProtocol.Core/Server/InMemoryMcpTaskStore.cs b/src/ModelContextProtocol.Core/Server/InMemoryMcpTaskStore.cs index d2515a1c4..b2550c100 100644 --- a/src/ModelContextProtocol.Core/Server/InMemoryMcpTaskStore.cs +++ b/src/ModelContextProtocol.Core/Server/InMemoryMcpTaskStore.cs @@ -39,6 +39,9 @@ public sealed class InMemoryMcpTaskStore : IMcpTaskStore, IDisposable private readonly int _pageSize; private readonly int? _maxTasks; private readonly int? _maxTasksPerSession; + private long _lastTimestamp; + private long _counter; + private readonly object _guidLock = new(); #if MCP_TEST_TIME_PROVIDER private readonly TimeProvider _timeProvider; #endif @@ -363,21 +366,20 @@ public Task ListTasksAsync( } } - // Stream enumeration - filter by session, exclude expired, order by (CreatedAt, TaskId) for stable pagination - // Must sort BEFORE applying keyset filter to ensure consistent comparison - IEnumerable query = _tasks.Values + // 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)) - .OrderBy(e => (e.CreatedAt, e.TaskId)); + .Where(e => !IsExpired(e)); // Apply keyset filter if cursor provided: (CreatedAt, TaskId) > cursor - // This runs on sorted data, so we skip items until we pass the cursor position if (parsedCursor is { } parsedCursorValue) { - query = query.SkipWhile(e => (e.CreatedAt, e.TaskId).CompareTo(parsedCursorValue) <= 0); + query = query.Where(e => (e.CreatedAt, e.TaskId).CompareTo(parsedCursorValue) > 0); } + // Order by (CreatedAt, TaskId) for stable, deterministic pagination var page = query + .OrderBy(e => (e.CreatedAt, e.TaskId)) .Take(_pageSize + 1) // Take one extra to check if there's a next page .Select(e => e.ToMcpTask()) .ToList(); @@ -448,12 +450,81 @@ public void Dispose() _cleanupTimer?.Dispose(); } -#if NET9_0_OR_GREATER - private static string GenerateTaskId() => Guid.CreateVersion7().ToString("N"); + /// + /// Generates a monotonically increasing task ID using UUID v7 format. + /// Uses a counter for intra-millisecond ordering to ensure strict monotonicity. + /// + private string GenerateTaskId() + { + // 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 timestamp; + long counter; + + lock (_guidLock) + { + timestamp = GetUtcNow().ToUnixTimeMilliseconds(); + + if (timestamp == _lastTimestamp) + { + // Same millisecond - increment counter + _counter++; + } + else + { + // New millisecond - reset counter + _lastTimestamp = timestamp; + _counter = 0; + } + + counter = _counter; + } + + byte[] bytes = new byte[16]; + + // Fill lower random bits (last 8 bytes) with random data +#if NETSTANDARD2_0 + using (var rng = System.Security.Cryptography.RandomNumberGenerator.Create()) + { + rng.GetBytes(bytes, 8, 8); + } #else - private static string GenerateTaskId() => GuidPolyfills.CreateVersion7().ToString("N"); + System.Security.Cryptography.RandomNumberGenerator.Fill(bytes.AsSpan(8, 8)); #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); + + // 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 + var guid = 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]); + + return guid.ToString("N"); + } + private static bool IsTerminalStatus(McpTaskStatus status) => status is McpTaskStatus.Completed or McpTaskStatus.Failed or McpTaskStatus.Cancelled; diff --git a/tests/ModelContextProtocol.Tests/Server/InMemoryMcpTaskStoreTests.cs b/tests/ModelContextProtocol.Tests/Server/InMemoryMcpTaskStoreTests.cs index 7a65b8afd..5b9db455a 100644 --- a/tests/ModelContextProtocol.Tests/Server/InMemoryMcpTaskStoreTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/InMemoryMcpTaskStoreTests.cs @@ -1090,4 +1090,84 @@ public async Task ListTasksAsync_KeysetPaginationWorksWithIdenticalTimestamps() 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(); + 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(); + 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(); + 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); + } } From a637ea2ec5b45ce0f8e51d75a8985b7059a91353 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 30 Jan 2026 14:36:15 +0000 Subject: [PATCH 12/24] Move UUID v7 generation logic back to GuidPolyfills class Co-authored-by: eiriktsarpalis <2813363+eiriktsarpalis@users.noreply.github.com> --- src/Common/Polyfills/System/GuidPolyfills.cs | 83 +++++++++++++++++++ .../Server/InMemoryMcpTaskStore.cs | 79 +----------------- .../ModelContextProtocol.Tests.csproj | 1 + 3 files changed, 86 insertions(+), 77 deletions(-) create mode 100644 src/Common/Polyfills/System/GuidPolyfills.cs diff --git a/src/Common/Polyfills/System/GuidPolyfills.cs b/src/Common/Polyfills/System/GuidPolyfills.cs new file mode 100644 index 000000000..7cdf2a2fa --- /dev/null +++ b/src/Common/Polyfills/System/GuidPolyfills.cs @@ -0,0 +1,83 @@ +namespace System; + +/// +/// Provides polyfills for GUID generation methods not available in older .NET versions. +/// +internal static class GuidPolyfills +{ + private static long s_lastTimestamp; + private static long s_counter; + private static readonly object s_lock = new(); + + /// + /// Creates a monotonically increasing GUID using UUID v7 format with the specified timestamp. + /// Uses a counter for intra-millisecond ordering to ensure strict monotonicity. + /// + /// The Unix timestamp in milliseconds to embed in the GUID. + /// A new monotonically increasing GUID. + 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 + s_lastTimestamp = timestamp; + s_counter = 0; + } + + counter = s_counter; + } + + byte[] bytes = new byte[16]; + + // 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)); +#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); + + // 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]); + } +} diff --git a/src/ModelContextProtocol.Core/Server/InMemoryMcpTaskStore.cs b/src/ModelContextProtocol.Core/Server/InMemoryMcpTaskStore.cs index b2550c100..ccab8ff12 100644 --- a/src/ModelContextProtocol.Core/Server/InMemoryMcpTaskStore.cs +++ b/src/ModelContextProtocol.Core/Server/InMemoryMcpTaskStore.cs @@ -39,9 +39,6 @@ public sealed class InMemoryMcpTaskStore : IMcpTaskStore, IDisposable private readonly int _pageSize; private readonly int? _maxTasks; private readonly int? _maxTasksPerSession; - private long _lastTimestamp; - private long _counter; - private readonly object _guidLock = new(); #if MCP_TEST_TIME_PROVIDER private readonly TimeProvider _timeProvider; #endif @@ -450,80 +447,8 @@ public void Dispose() _cleanupTimer?.Dispose(); } - /// - /// Generates a monotonically increasing task ID using UUID v7 format. - /// Uses a counter for intra-millisecond ordering to ensure strict monotonicity. - /// - private string GenerateTaskId() - { - // 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 timestamp; - long counter; - - lock (_guidLock) - { - timestamp = GetUtcNow().ToUnixTimeMilliseconds(); - - if (timestamp == _lastTimestamp) - { - // Same millisecond - increment counter - _counter++; - } - else - { - // New millisecond - reset counter - _lastTimestamp = timestamp; - _counter = 0; - } - - counter = _counter; - } - - byte[] bytes = new byte[16]; - - // Fill lower random bits (last 8 bytes) with random data -#if NETSTANDARD2_0 - using (var rng = System.Security.Cryptography.RandomNumberGenerator.Create()) - { - rng.GetBytes(bytes, 8, 8); - } -#else - System.Security.Cryptography.RandomNumberGenerator.Fill(bytes.AsSpan(8, 8)); -#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); - - // 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 - var guid = 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]); - - return guid.ToString("N"); - } + private string GenerateTaskId() => + GuidPolyfills.CreateMonotonicUuid(GetUtcNow().ToUnixTimeMilliseconds()).ToString("N"); private static bool IsTerminalStatus(McpTaskStatus status) => status is McpTaskStatus.Completed or McpTaskStatus.Failed or McpTaskStatus.Cancelled; diff --git a/tests/ModelContextProtocol.Tests/ModelContextProtocol.Tests.csproj b/tests/ModelContextProtocol.Tests/ModelContextProtocol.Tests.csproj index b8d4f3b23..582ddcfe4 100644 --- a/tests/ModelContextProtocol.Tests/ModelContextProtocol.Tests.csproj +++ b/tests/ModelContextProtocol.Tests/ModelContextProtocol.Tests.csproj @@ -33,6 +33,7 @@ + From 8ef53713aa553bcf8739349d518fdaced402a47c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 30 Jan 2026 15:05:09 +0000 Subject: [PATCH 13/24] Simplify pagination cursor to use only TaskId since UUID v7 is monotonically increasing Co-authored-by: eiriktsarpalis <2813363+eiriktsarpalis@users.noreply.github.com> --- .../Server/InMemoryMcpTaskStore.cs | 26 ++++++------------- 1 file changed, 8 insertions(+), 18 deletions(-) diff --git a/src/ModelContextProtocol.Core/Server/InMemoryMcpTaskStore.cs b/src/ModelContextProtocol.Core/Server/InMemoryMcpTaskStore.cs index ccab8ff12..b14aae951 100644 --- a/src/ModelContextProtocol.Core/Server/InMemoryMcpTaskStore.cs +++ b/src/ModelContextProtocol.Core/Server/InMemoryMcpTaskStore.cs @@ -351,32 +351,22 @@ public Task ListTasksAsync( 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(); @@ -386,7 +376,7 @@ public Task ListTasksAsync( 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 From ef86c4d57b382cac5a8cc8e9ab5391669e466273 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 30 Jan 2026 15:11:13 +0000 Subject: [PATCH 14/24] Rename polyfill to CreateVersion7(DateTimeOffset) to match .NET 9 API Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com> --- src/Common/Polyfills/System/GuidPolyfills.cs | 34 ++++++++++++------- .../Server/InMemoryMcpTaskStore.cs | 2 +- 2 files changed, 22 insertions(+), 14 deletions(-) diff --git a/src/Common/Polyfills/System/GuidPolyfills.cs b/src/Common/Polyfills/System/GuidPolyfills.cs index 7cdf2a2fa..5cc21c9a6 100644 --- a/src/Common/Polyfills/System/GuidPolyfills.cs +++ b/src/Common/Polyfills/System/GuidPolyfills.cs @@ -1,7 +1,8 @@ namespace System; /// -/// Provides polyfills for GUID generation methods not available in older .NET versions. +/// Provides polyfills for GUID generation methods not available in older .NET versions, +/// with monotonic counter-based ordering for strict intra-millisecond sequencing. /// internal static class GuidPolyfills { @@ -10,12 +11,17 @@ internal static class GuidPolyfills private static readonly object s_lock = new(); /// - /// Creates a monotonically increasing GUID using UUID v7 format with the specified timestamp. + /// Creates a UUID v7 GUID with the specified timestamp. /// Uses a counter for intra-millisecond ordering to ensure strict monotonicity. /// - /// The Unix timestamp in milliseconds to embed in the GUID. - /// A new monotonically increasing GUID. - public static Guid CreateMonotonicUuid(long timestamp) + /// The timestamp to embed in the GUID. + /// A new UUID v7 GUID. + /// + /// Unlike the built-in Guid.CreateVersion7(DateTimeOffset) in .NET 9+, + /// this implementation uses a counter to ensure strict monotonicity within the same millisecond, + /// which is required for keyset pagination to work correctly. + /// + public static Guid CreateVersion7(DateTimeOffset timestamp) { // UUID v7 format (RFC 9562): // - 48 bits: Unix timestamp in milliseconds (big-endian) @@ -24,11 +30,12 @@ public static Guid CreateMonotonicUuid(long timestamp) // - 2 bits: variant (10) // - 62 bits: random + long timestampMs = timestamp.ToUnixTimeMilliseconds(); long counter; lock (s_lock) { - if (timestamp == s_lastTimestamp) + if (timestampMs == s_lastTimestamp) { // Same millisecond - increment counter s_counter++; @@ -36,7 +43,7 @@ public static Guid CreateMonotonicUuid(long timestamp) else { // New millisecond - reset counter - s_lastTimestamp = timestamp; + s_lastTimestamp = timestampMs; s_counter = 0; } @@ -56,12 +63,12 @@ public static Guid CreateMonotonicUuid(long timestamp) #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; + bytes[0] = (byte)(timestampMs >> 40); + bytes[1] = (byte)(timestampMs >> 32); + bytes[2] = (byte)(timestampMs >> 24); + bytes[3] = (byte)(timestampMs >> 16); + bytes[4] = (byte)(timestampMs >> 8); + bytes[5] = (byte)timestampMs; // 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)); @@ -81,3 +88,4 @@ public static Guid CreateMonotonicUuid(long timestamp) bytes[12], bytes[13], bytes[14], bytes[15]); } } + diff --git a/src/ModelContextProtocol.Core/Server/InMemoryMcpTaskStore.cs b/src/ModelContextProtocol.Core/Server/InMemoryMcpTaskStore.cs index b14aae951..237241c8c 100644 --- a/src/ModelContextProtocol.Core/Server/InMemoryMcpTaskStore.cs +++ b/src/ModelContextProtocol.Core/Server/InMemoryMcpTaskStore.cs @@ -438,7 +438,7 @@ public void Dispose() } private string GenerateTaskId() => - GuidPolyfills.CreateMonotonicUuid(GetUtcNow().ToUnixTimeMilliseconds()).ToString("N"); + GuidPolyfills.CreateVersion7(GetUtcNow()).ToString("N"); private static bool IsTerminalStatus(McpTaskStatus status) => status is McpTaskStatus.Completed or McpTaskStatus.Failed or McpTaskStatus.Cancelled; From 676aecb2d870d1216eff2e781d91f2b1f35f1579 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 30 Jan 2026 15:23:49 +0000 Subject: [PATCH 15/24] Revert to CreateMonotonicUuid name and document why CreateVersion7 can't be used Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com> --- src/Common/Polyfills/System/GuidPolyfills.cs | 23 +++++++++++-------- .../Server/InMemoryMcpTaskStore.cs | 2 +- 2 files changed, 15 insertions(+), 10 deletions(-) diff --git a/src/Common/Polyfills/System/GuidPolyfills.cs b/src/Common/Polyfills/System/GuidPolyfills.cs index 5cc21c9a6..163a3813b 100644 --- a/src/Common/Polyfills/System/GuidPolyfills.cs +++ b/src/Common/Polyfills/System/GuidPolyfills.cs @@ -1,8 +1,7 @@ namespace System; /// -/// Provides polyfills for GUID generation methods not available in older .NET versions, -/// with monotonic counter-based ordering for strict intra-millisecond sequencing. +/// Provides polyfills for GUID generation methods not available in older .NET versions. /// internal static class GuidPolyfills { @@ -11,17 +10,24 @@ internal static class GuidPolyfills private static readonly object s_lock = new(); /// - /// Creates a UUID v7 GUID with the specified timestamp. + /// Creates a monotonically increasing UUID v7 GUID with the specified timestamp. /// Uses a counter for intra-millisecond ordering to ensure strict monotonicity. /// /// The timestamp to embed in the GUID. - /// A new UUID v7 GUID. + /// A new monotonically increasing UUID v7 GUID. /// - /// Unlike the built-in Guid.CreateVersion7(DateTimeOffset) in .NET 9+, - /// this implementation uses a counter to ensure strict monotonicity within the same millisecond, - /// which is required for keyset pagination to work correctly. + /// + /// This method cannot be replaced with Guid.CreateVersion7(DateTimeOffset) because + /// the built-in .NET implementation uses random bits for intra-millisecond uniqueness, + /// which does not guarantee strict monotonicity. For keyset pagination to work correctly, + /// GUIDs created within the same millisecond must be strictly ordered by creation time. + /// + /// + /// This implementation uses RFC 9562's optional counter mechanism to ensure that + /// multiple GUIDs generated within the same millisecond are strictly monotonically increasing. + /// /// - public static Guid CreateVersion7(DateTimeOffset timestamp) + public static Guid CreateMonotonicUuid(DateTimeOffset timestamp) { // UUID v7 format (RFC 9562): // - 48 bits: Unix timestamp in milliseconds (big-endian) @@ -88,4 +94,3 @@ public static Guid CreateVersion7(DateTimeOffset timestamp) bytes[12], bytes[13], bytes[14], bytes[15]); } } - diff --git a/src/ModelContextProtocol.Core/Server/InMemoryMcpTaskStore.cs b/src/ModelContextProtocol.Core/Server/InMemoryMcpTaskStore.cs index 237241c8c..775308af2 100644 --- a/src/ModelContextProtocol.Core/Server/InMemoryMcpTaskStore.cs +++ b/src/ModelContextProtocol.Core/Server/InMemoryMcpTaskStore.cs @@ -438,7 +438,7 @@ public void Dispose() } private string GenerateTaskId() => - GuidPolyfills.CreateVersion7(GetUtcNow()).ToString("N"); + GuidPolyfills.CreateMonotonicUuid(GetUtcNow()).ToString("N"); private static bool IsTerminalStatus(McpTaskStatus status) => status is McpTaskStatus.Completed or McpTaskStatus.Failed or McpTaskStatus.Cancelled; From 08775890129cbbe50fd4f0beb5a64541a3af8980 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 30 Jan 2026 15:50:48 +0000 Subject: [PATCH 16/24] Refactor GuidPolyfills to GuidHelpers with simplified monotonic counter Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com> --- src/Common/Polyfills/System/GuidHelpers.cs | 80 ++++++++++++++++ src/Common/Polyfills/System/GuidPolyfills.cs | 96 ------------------- .../Server/InMemoryMcpTaskStore.cs | 2 +- .../ModelContextProtocol.Tests.csproj | 2 +- 4 files changed, 82 insertions(+), 98 deletions(-) create mode 100644 src/Common/Polyfills/System/GuidHelpers.cs delete mode 100644 src/Common/Polyfills/System/GuidPolyfills.cs diff --git a/src/Common/Polyfills/System/GuidHelpers.cs b/src/Common/Polyfills/System/GuidHelpers.cs new file mode 100644 index 000000000..19b761b1a --- /dev/null +++ b/src/Common/Polyfills/System/GuidHelpers.cs @@ -0,0 +1,80 @@ +using System.Threading; + +namespace System; + +/// +/// Provides helper methods for GUID generation. +/// +internal static class GuidHelpers +{ + private static long s_counter; + + /// + /// Creates a monotonically increasing UUID v7 GUID with the specified timestamp. + /// Uses a globally increasing counter for strict monotonicity. + /// + /// The timestamp to embed in the GUID. + /// A new monotonically increasing UUID v7 GUID. + /// + /// + /// This method cannot be replaced with Guid.CreateVersion7(DateTimeOffset) because + /// the built-in .NET implementation uses random bits for intra-millisecond uniqueness, + /// which does not guarantee strict monotonicity. For keyset pagination to work correctly, + /// GUIDs created within the same millisecond must be strictly ordered by creation time. + /// + /// + /// This implementation uses a globally monotonically increasing counter to ensure that + /// all generated GUIDs are strictly ordered by creation time, regardless of timestamp. + /// + /// + public static Guid CreateMonotonicUuid(DateTimeOffset 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 timestampMs = timestamp.ToUnixTimeMilliseconds(); + long counter = Interlocked.Increment(ref s_counter); + + // Start with a random GUID and twiddle the relevant bits + Guid baseGuid = Guid.NewGuid(); + +#if NETSTANDARD2_0 + byte[] bytes = baseGuid.ToByteArray(); +#else + Span bytes = stackalloc byte[16]; + baseGuid.TryWriteBytes(bytes); +#endif + + // Guid.ToByteArray() returns bytes in little-endian order for the first 3 components, + // but we need big-endian for UUID v7. The byte layout from ToByteArray() is: + // [0-3]: Data1 (little-endian int) + // [4-5]: Data2 (little-endian short) + // [6-7]: Data3 (little-endian short) + // [8-15]: Data4 (byte array, unchanged) + + // Set timestamp (48 bits) - need to account for little-endian layout + // Data1 (bytes 0-3, little-endian) contains timestamp bits 0-31 + bytes[0] = (byte)(timestampMs >> 8); + bytes[1] = (byte)(timestampMs >> 16); + bytes[2] = (byte)(timestampMs >> 24); + bytes[3] = (byte)(timestampMs >> 32); + + // Data2 (bytes 4-5, little-endian) contains timestamp bits 32-47 + bytes[4] = (byte)timestampMs; + bytes[5] = (byte)(timestampMs >> 40); + + // Data3 (bytes 6-7, little-endian) contains version (4 bits) + counter high (12 bits) + // Version 7 = 0111, counter uses 12 bits + bytes[6] = (byte)(counter & 0xFF); + bytes[7] = (byte)(0x70 | ((counter >> 8) & 0x0F)); + + // Set variant (10) in high 2 bits of byte 8 + bytes[8] = (byte)((bytes[8] & 0x3F) | 0x80); + + return new Guid(bytes); + } +} diff --git a/src/Common/Polyfills/System/GuidPolyfills.cs b/src/Common/Polyfills/System/GuidPolyfills.cs deleted file mode 100644 index 163a3813b..000000000 --- a/src/Common/Polyfills/System/GuidPolyfills.cs +++ /dev/null @@ -1,96 +0,0 @@ -namespace System; - -/// -/// Provides polyfills for GUID generation methods not available in older .NET versions. -/// -internal static class GuidPolyfills -{ - private static long s_lastTimestamp; - private static long s_counter; - private static readonly object s_lock = new(); - - /// - /// Creates a monotonically increasing UUID v7 GUID with the specified timestamp. - /// Uses a counter for intra-millisecond ordering to ensure strict monotonicity. - /// - /// The timestamp to embed in the GUID. - /// A new monotonically increasing UUID v7 GUID. - /// - /// - /// This method cannot be replaced with Guid.CreateVersion7(DateTimeOffset) because - /// the built-in .NET implementation uses random bits for intra-millisecond uniqueness, - /// which does not guarantee strict monotonicity. For keyset pagination to work correctly, - /// GUIDs created within the same millisecond must be strictly ordered by creation time. - /// - /// - /// This implementation uses RFC 9562's optional counter mechanism to ensure that - /// multiple GUIDs generated within the same millisecond are strictly monotonically increasing. - /// - /// - public static Guid CreateMonotonicUuid(DateTimeOffset 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 timestampMs = timestamp.ToUnixTimeMilliseconds(); - long counter; - - lock (s_lock) - { - if (timestampMs == s_lastTimestamp) - { - // Same millisecond - increment counter - s_counter++; - } - else - { - // New millisecond - reset counter - s_lastTimestamp = timestampMs; - s_counter = 0; - } - - counter = s_counter; - } - - byte[] bytes = new byte[16]; - - // 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)); -#endif - - // Set timestamp (48 bits, big-endian) in first 6 bytes - bytes[0] = (byte)(timestampMs >> 40); - bytes[1] = (byte)(timestampMs >> 32); - bytes[2] = (byte)(timestampMs >> 24); - bytes[3] = (byte)(timestampMs >> 16); - bytes[4] = (byte)(timestampMs >> 8); - bytes[5] = (byte)timestampMs; - - // 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); - - // 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]); - } -} diff --git a/src/ModelContextProtocol.Core/Server/InMemoryMcpTaskStore.cs b/src/ModelContextProtocol.Core/Server/InMemoryMcpTaskStore.cs index 775308af2..e4e9cc1f3 100644 --- a/src/ModelContextProtocol.Core/Server/InMemoryMcpTaskStore.cs +++ b/src/ModelContextProtocol.Core/Server/InMemoryMcpTaskStore.cs @@ -438,7 +438,7 @@ public void Dispose() } private string GenerateTaskId() => - GuidPolyfills.CreateMonotonicUuid(GetUtcNow()).ToString("N"); + GuidHelpers.CreateMonotonicUuid(GetUtcNow()).ToString("N"); private static bool IsTerminalStatus(McpTaskStatus status) => status is McpTaskStatus.Completed or McpTaskStatus.Failed or McpTaskStatus.Cancelled; diff --git a/tests/ModelContextProtocol.Tests/ModelContextProtocol.Tests.csproj b/tests/ModelContextProtocol.Tests/ModelContextProtocol.Tests.csproj index 582ddcfe4..669b1e0a4 100644 --- a/tests/ModelContextProtocol.Tests/ModelContextProtocol.Tests.csproj +++ b/tests/ModelContextProtocol.Tests/ModelContextProtocol.Tests.csproj @@ -33,7 +33,7 @@ - + From 7ea40648e36aa07325e49ba0e770365b49f1c64d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 30 Jan 2026 16:21:46 +0000 Subject: [PATCH 17/24] Simplify GUID creation using unsafe pointer manipulation Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com> --- src/Common/Polyfills/System/GuidHelpers.cs | 45 +++++-------------- src/Directory.Build.props | 1 + .../ModelContextProtocol.Core.csproj | 1 - .../ModelContextProtocol.Tests.csproj | 1 + 4 files changed, 13 insertions(+), 35 deletions(-) diff --git a/src/Common/Polyfills/System/GuidHelpers.cs b/src/Common/Polyfills/System/GuidHelpers.cs index 19b761b1a..c5a59ae94 100644 --- a/src/Common/Polyfills/System/GuidHelpers.cs +++ b/src/Common/Polyfills/System/GuidHelpers.cs @@ -27,7 +27,7 @@ internal static class GuidHelpers /// all generated GUIDs are strictly ordered by creation time, regardless of timestamp. /// /// - public static Guid CreateMonotonicUuid(DateTimeOffset timestamp) + public static unsafe Guid CreateMonotonicUuid(DateTimeOffset timestamp) { // UUID v7 format (RFC 9562): // - 48 bits: Unix timestamp in milliseconds (big-endian) @@ -40,41 +40,18 @@ public static Guid CreateMonotonicUuid(DateTimeOffset timestamp) long counter = Interlocked.Increment(ref s_counter); // Start with a random GUID and twiddle the relevant bits - Guid baseGuid = Guid.NewGuid(); + Guid guid = Guid.NewGuid(); -#if NETSTANDARD2_0 - byte[] bytes = baseGuid.ToByteArray(); -#else - Span bytes = stackalloc byte[16]; - baseGuid.TryWriteBytes(bytes); -#endif + int* guidAsInts = (int*)&guid; + short* guidAsShorts = (short*)&guid; + byte* guidAsBytes = (byte*)&guid; - // Guid.ToByteArray() returns bytes in little-endian order for the first 3 components, - // but we need big-endian for UUID v7. The byte layout from ToByteArray() is: - // [0-3]: Data1 (little-endian int) - // [4-5]: Data2 (little-endian short) - // [6-7]: Data3 (little-endian short) - // [8-15]: Data4 (byte array, unchanged) + // Set timestamp (48 bits) and version/counter using little-endian layout + guidAsInts[0] = (int)(timestampMs >> 8); + guidAsShorts[2] = (short)((timestampMs & 0xFF) | ((timestampMs >> 40) << 8)); + guidAsShorts[3] = (short)((counter & 0xFFF) | 0x7000); + guidAsBytes[8] = (byte)((guidAsBytes[8] & 0x3F) | 0x80); - // Set timestamp (48 bits) - need to account for little-endian layout - // Data1 (bytes 0-3, little-endian) contains timestamp bits 0-31 - bytes[0] = (byte)(timestampMs >> 8); - bytes[1] = (byte)(timestampMs >> 16); - bytes[2] = (byte)(timestampMs >> 24); - bytes[3] = (byte)(timestampMs >> 32); - - // Data2 (bytes 4-5, little-endian) contains timestamp bits 32-47 - bytes[4] = (byte)timestampMs; - bytes[5] = (byte)(timestampMs >> 40); - - // Data3 (bytes 6-7, little-endian) contains version (4 bits) + counter high (12 bits) - // Version 7 = 0111, counter uses 12 bits - bytes[6] = (byte)(counter & 0xFF); - bytes[7] = (byte)(0x70 | ((counter >> 8) & 0x0F)); - - // Set variant (10) in high 2 bits of byte 8 - bytes[8] = (byte)((bytes[8] & 0x3F) | 0x80); - - return new Guid(bytes); + return guid; } } diff --git a/src/Directory.Build.props b/src/Directory.Build.props index c7d089c85..4c5b73290 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -17,6 +17,7 @@ true $(RepoRoot)\Open.snk true + true diff --git a/src/ModelContextProtocol.Core/ModelContextProtocol.Core.csproj b/src/ModelContextProtocol.Core/ModelContextProtocol.Core.csproj index 63b7bbd45..b5d6b456e 100644 --- a/src/ModelContextProtocol.Core/ModelContextProtocol.Core.csproj +++ b/src/ModelContextProtocol.Core/ModelContextProtocol.Core.csproj @@ -7,7 +7,6 @@ ModelContextProtocol.Core Core .NET SDK for the Model Context Protocol (MCP) README.md - True $(NoWarn);MCPEXP001 diff --git a/tests/ModelContextProtocol.Tests/ModelContextProtocol.Tests.csproj b/tests/ModelContextProtocol.Tests/ModelContextProtocol.Tests.csproj index 669b1e0a4..decc89b77 100644 --- a/tests/ModelContextProtocol.Tests/ModelContextProtocol.Tests.csproj +++ b/tests/ModelContextProtocol.Tests/ModelContextProtocol.Tests.csproj @@ -5,6 +5,7 @@ net10.0;net9.0;net8.0;net472 enable enable + true false true From e17be78ce970783a69d89ae69b6dfe71e13301a4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 30 Jan 2026 16:28:32 +0000 Subject: [PATCH 18/24] Fix test project duplicate compile for GuidHelpers.cs on net472 Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com> --- .../ModelContextProtocol.Tests.csproj | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/ModelContextProtocol.Tests/ModelContextProtocol.Tests.csproj b/tests/ModelContextProtocol.Tests/ModelContextProtocol.Tests.csproj index decc89b77..491c9baac 100644 --- a/tests/ModelContextProtocol.Tests/ModelContextProtocol.Tests.csproj +++ b/tests/ModelContextProtocol.Tests/ModelContextProtocol.Tests.csproj @@ -34,7 +34,6 @@ - @@ -42,6 +41,10 @@ + + + + runtime; build; native; contentfiles; analyzers; buildtransitive From a9f8a4df6a90acc5a59ed3d15dc7b9a9ca88ca5a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 30 Jan 2026 16:31:27 +0000 Subject: [PATCH 19/24] Scope unsafe block to only the pointer manipulation lines Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com> --- src/Common/Polyfills/System/GuidHelpers.cs | 23 ++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/src/Common/Polyfills/System/GuidHelpers.cs b/src/Common/Polyfills/System/GuidHelpers.cs index c5a59ae94..29e975b30 100644 --- a/src/Common/Polyfills/System/GuidHelpers.cs +++ b/src/Common/Polyfills/System/GuidHelpers.cs @@ -27,7 +27,7 @@ internal static class GuidHelpers /// all generated GUIDs are strictly ordered by creation time, regardless of timestamp. /// /// - public static unsafe Guid CreateMonotonicUuid(DateTimeOffset timestamp) + public static Guid CreateMonotonicUuid(DateTimeOffset timestamp) { // UUID v7 format (RFC 9562): // - 48 bits: Unix timestamp in milliseconds (big-endian) @@ -42,15 +42,18 @@ public static unsafe Guid CreateMonotonicUuid(DateTimeOffset timestamp) // Start with a random GUID and twiddle the relevant bits Guid guid = Guid.NewGuid(); - int* guidAsInts = (int*)&guid; - short* guidAsShorts = (short*)&guid; - byte* guidAsBytes = (byte*)&guid; - - // Set timestamp (48 bits) and version/counter using little-endian layout - guidAsInts[0] = (int)(timestampMs >> 8); - guidAsShorts[2] = (short)((timestampMs & 0xFF) | ((timestampMs >> 40) << 8)); - guidAsShorts[3] = (short)((counter & 0xFFF) | 0x7000); - guidAsBytes[8] = (byte)((guidAsBytes[8] & 0x3F) | 0x80); + unsafe + { + int* guidAsInts = (int*)&guid; + short* guidAsShorts = (short*)&guid; + byte* guidAsBytes = (byte*)&guid; + + // Set timestamp (48 bits) and version/counter using little-endian layout + guidAsInts[0] = (int)(timestampMs >> 8); + guidAsShorts[2] = (short)((timestampMs & 0xFF) | ((timestampMs >> 40) << 8)); + guidAsShorts[3] = (short)((counter & 0xFFF) | 0x7000); + guidAsBytes[8] = (byte)((guidAsBytes[8] & 0x3F) | 0x80); + } return guid; } From 446477d2fb897224d013b6c2880edfc01896986c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 30 Jan 2026 16:38:46 +0000 Subject: [PATCH 20/24] Clarify comment that this is UUIDv7-like format, not RFC-compliant Co-authored-by: eiriktsarpalis <2813363+eiriktsarpalis@users.noreply.github.com> --- src/Common/Polyfills/System/GuidHelpers.cs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/Common/Polyfills/System/GuidHelpers.cs b/src/Common/Polyfills/System/GuidHelpers.cs index 29e975b30..1e4ab12de 100644 --- a/src/Common/Polyfills/System/GuidHelpers.cs +++ b/src/Common/Polyfills/System/GuidHelpers.cs @@ -10,11 +10,11 @@ internal static class GuidHelpers private static long s_counter; /// - /// Creates a monotonically increasing UUID v7 GUID with the specified timestamp. + /// Creates a monotonically increasing GUID using a UUIDv7-like format with the specified timestamp. /// Uses a globally increasing counter for strict monotonicity. /// /// The timestamp to embed in the GUID. - /// A new monotonically increasing UUID v7 GUID. + /// A new monotonically increasing GUID. /// /// /// This method cannot be replaced with Guid.CreateVersion7(DateTimeOffset) because @@ -25,11 +25,13 @@ internal static class GuidHelpers /// /// This implementation uses a globally monotonically increasing counter to ensure that /// all generated GUIDs are strictly ordered by creation time, regardless of timestamp. + /// The format is UUIDv7-like but not RFC 9562 compliant since we prioritize strict + /// monotonicity over random bits in the counter field. /// /// public static Guid CreateMonotonicUuid(DateTimeOffset timestamp) { - // UUID v7 format (RFC 9562): + // UUIDv7-like format (based on RFC 9562 structure, but uses counter instead of random for strict monotonicity): // - 48 bits: Unix timestamp in milliseconds (big-endian) // - 4 bits: version (0111 = 7) // - 12 bits: counter/sequence (for intra-millisecond ordering) From 0854883d07258b3bafc58b2e64ac266cf651a179 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 30 Jan 2026 16:43:40 +0000 Subject: [PATCH 21/24] Move AllowUnsafeBlocks to specific project csproj files Co-authored-by: eiriktsarpalis <2813363+eiriktsarpalis@users.noreply.github.com> --- src/Directory.Build.props | 1 - .../ModelContextProtocol.AspNetCore.csproj | 2 ++ src/ModelContextProtocol.Core/ModelContextProtocol.Core.csproj | 2 ++ src/ModelContextProtocol/ModelContextProtocol.csproj | 2 ++ 4 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 4c5b73290..c7d089c85 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -17,7 +17,6 @@ true $(RepoRoot)\Open.snk true - true diff --git a/src/ModelContextProtocol.AspNetCore/ModelContextProtocol.AspNetCore.csproj b/src/ModelContextProtocol.AspNetCore/ModelContextProtocol.AspNetCore.csproj index a957bd969..54ea8cac1 100644 --- a/src/ModelContextProtocol.AspNetCore/ModelContextProtocol.AspNetCore.csproj +++ b/src/ModelContextProtocol.AspNetCore/ModelContextProtocol.AspNetCore.csproj @@ -10,6 +10,8 @@ ASP.NET Core extensions for the C# Model Context Protocol (MCP) SDK. README.md true + + true diff --git a/src/ModelContextProtocol.Core/ModelContextProtocol.Core.csproj b/src/ModelContextProtocol.Core/ModelContextProtocol.Core.csproj index b5d6b456e..37e666b29 100644 --- a/src/ModelContextProtocol.Core/ModelContextProtocol.Core.csproj +++ b/src/ModelContextProtocol.Core/ModelContextProtocol.Core.csproj @@ -9,6 +9,8 @@ README.md $(NoWarn);MCPEXP001 + + true diff --git a/src/ModelContextProtocol/ModelContextProtocol.csproj b/src/ModelContextProtocol/ModelContextProtocol.csproj index fcf549c0a..817882c3b 100644 --- a/src/ModelContextProtocol/ModelContextProtocol.csproj +++ b/src/ModelContextProtocol/ModelContextProtocol.csproj @@ -9,6 +9,8 @@ README.md $(NoWarn);MCPEXP001 + + true From fe42850603c0e0f25ee01de8bd01e3364f39e3f1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 30 Jan 2026 18:56:02 +0000 Subject: [PATCH 22/24] Use two longs (ticks + counter) for monotonic ID generation instead of UUIDv7-like format Co-authored-by: eiriktsarpalis <2813363+eiriktsarpalis@users.noreply.github.com> --- src/Common/Polyfills/System/GuidHelpers.cs | 54 ++++++------------- .../ModelContextProtocol.AspNetCore.csproj | 2 - .../ModelContextProtocol.Core.csproj | 4 +- .../Server/InMemoryMcpTaskStore.cs | 2 +- .../ModelContextProtocol.csproj | 2 - 5 files changed, 19 insertions(+), 45 deletions(-) diff --git a/src/Common/Polyfills/System/GuidHelpers.cs b/src/Common/Polyfills/System/GuidHelpers.cs index 1e4ab12de..5379fe303 100644 --- a/src/Common/Polyfills/System/GuidHelpers.cs +++ b/src/Common/Polyfills/System/GuidHelpers.cs @@ -10,53 +10,31 @@ internal static class GuidHelpers private static long s_counter; /// - /// Creates a monotonically increasing GUID using a UUIDv7-like format with the specified timestamp. - /// Uses a globally increasing counter for strict monotonicity. + /// 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). /// - /// The timestamp to embed in the GUID. - /// A new monotonically increasing GUID. + /// The timestamp to embed in the identifier. + /// A new strictly monotonically increasing identifier string. /// /// - /// This method cannot be replaced with Guid.CreateVersion7(DateTimeOffset) because - /// the built-in .NET implementation uses random bits for intra-millisecond uniqueness, - /// which does not guarantee strict monotonicity. For keyset pagination to work correctly, - /// GUIDs created within the same millisecond must be strictly ordered by creation time. + /// This method creates a 128-bit identifier composed of two 64-bit values: + /// - High 64 bits: from the timestamp + /// - Low 64 bits: A globally monotonically increasing counter /// /// - /// This implementation uses a globally monotonically increasing counter to ensure that - /// all generated GUIDs are strictly ordered by creation time, regardless of timestamp. - /// The format is UUIDv7-like but not RFC 9562 compliant since we prioritize strict - /// monotonicity over random bits in the counter field. + /// The resulting string is strictly monotonically increasing when compared lexicographically, + /// which is required for keyset pagination to work correctly. Unlike Guid.CreateVersion7, + /// which uses random bits for intra-millisecond uniqueness, this implementation guarantees + /// strict ordering for all identifiers regardless of when they were created. /// /// - public static Guid CreateMonotonicUuid(DateTimeOffset timestamp) + public static string CreateMonotonicId(DateTimeOffset timestamp) { - // UUIDv7-like format (based on RFC 9562 structure, but uses counter instead of random for strict monotonicity): - // - 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 timestampMs = timestamp.ToUnixTimeMilliseconds(); + long ticks = timestamp.UtcTicks; long counter = Interlocked.Increment(ref s_counter); - // Start with a random GUID and twiddle the relevant bits - Guid guid = Guid.NewGuid(); - - unsafe - { - int* guidAsInts = (int*)&guid; - short* guidAsShorts = (short*)&guid; - byte* guidAsBytes = (byte*)&guid; - - // Set timestamp (48 bits) and version/counter using little-endian layout - guidAsInts[0] = (int)(timestampMs >> 8); - guidAsShorts[2] = (short)((timestampMs & 0xFF) | ((timestampMs >> 40) << 8)); - guidAsShorts[3] = (short)((counter & 0xFFF) | 0x7000); - guidAsBytes[8] = (byte)((guidAsBytes[8] & 0x3F) | 0x80); - } - - return guid; + // Format as 32-character hex string (16 bytes = 128 bits) + // High 64 bits: timestamp ticks, Low 64 bits: counter + return $"{ticks:x16}{counter:x16}"; } } diff --git a/src/ModelContextProtocol.AspNetCore/ModelContextProtocol.AspNetCore.csproj b/src/ModelContextProtocol.AspNetCore/ModelContextProtocol.AspNetCore.csproj index 54ea8cac1..a957bd969 100644 --- a/src/ModelContextProtocol.AspNetCore/ModelContextProtocol.AspNetCore.csproj +++ b/src/ModelContextProtocol.AspNetCore/ModelContextProtocol.AspNetCore.csproj @@ -10,8 +10,6 @@ ASP.NET Core extensions for the C# Model Context Protocol (MCP) SDK. README.md true - - true diff --git a/src/ModelContextProtocol.Core/ModelContextProtocol.Core.csproj b/src/ModelContextProtocol.Core/ModelContextProtocol.Core.csproj index 37e666b29..7780a5a83 100644 --- a/src/ModelContextProtocol.Core/ModelContextProtocol.Core.csproj +++ b/src/ModelContextProtocol.Core/ModelContextProtocol.Core.csproj @@ -9,8 +9,6 @@ README.md $(NoWarn);MCPEXP001 - - true @@ -20,6 +18,8 @@ $(NoWarn);CS0436 + + true diff --git a/src/ModelContextProtocol.Core/Server/InMemoryMcpTaskStore.cs b/src/ModelContextProtocol.Core/Server/InMemoryMcpTaskStore.cs index e4e9cc1f3..fd798b2a3 100644 --- a/src/ModelContextProtocol.Core/Server/InMemoryMcpTaskStore.cs +++ b/src/ModelContextProtocol.Core/Server/InMemoryMcpTaskStore.cs @@ -438,7 +438,7 @@ public void Dispose() } private string GenerateTaskId() => - GuidHelpers.CreateMonotonicUuid(GetUtcNow()).ToString("N"); + GuidHelpers.CreateMonotonicId(GetUtcNow()); private static bool IsTerminalStatus(McpTaskStatus status) => status is McpTaskStatus.Completed or McpTaskStatus.Failed or McpTaskStatus.Cancelled; diff --git a/src/ModelContextProtocol/ModelContextProtocol.csproj b/src/ModelContextProtocol/ModelContextProtocol.csproj index 817882c3b..fcf549c0a 100644 --- a/src/ModelContextProtocol/ModelContextProtocol.csproj +++ b/src/ModelContextProtocol/ModelContextProtocol.csproj @@ -9,8 +9,6 @@ README.md $(NoWarn);MCPEXP001 - - true From 5dc6a2151e470ee98ffabe9924ef9487cdc2cc56 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 30 Jan 2026 21:40:21 +0000 Subject: [PATCH 23/24] Rename GuidHelpers to IdHelpers since it no longer generates GUIDs Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com> --- src/Common/Polyfills/System/{GuidHelpers.cs => IdHelpers.cs} | 4 ++-- src/ModelContextProtocol.Core/Server/InMemoryMcpTaskStore.cs | 2 +- .../ModelContextProtocol.Tests.csproj | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) rename src/Common/Polyfills/System/{GuidHelpers.cs => IdHelpers.cs} (94%) diff --git a/src/Common/Polyfills/System/GuidHelpers.cs b/src/Common/Polyfills/System/IdHelpers.cs similarity index 94% rename from src/Common/Polyfills/System/GuidHelpers.cs rename to src/Common/Polyfills/System/IdHelpers.cs index 5379fe303..a698ccada 100644 --- a/src/Common/Polyfills/System/GuidHelpers.cs +++ b/src/Common/Polyfills/System/IdHelpers.cs @@ -3,9 +3,9 @@ namespace System; /// -/// Provides helper methods for GUID generation. +/// Provides helper methods for monotonic ID generation. /// -internal static class GuidHelpers +internal static class IdHelpers { private static long s_counter; diff --git a/src/ModelContextProtocol.Core/Server/InMemoryMcpTaskStore.cs b/src/ModelContextProtocol.Core/Server/InMemoryMcpTaskStore.cs index fd798b2a3..27156e98d 100644 --- a/src/ModelContextProtocol.Core/Server/InMemoryMcpTaskStore.cs +++ b/src/ModelContextProtocol.Core/Server/InMemoryMcpTaskStore.cs @@ -438,7 +438,7 @@ public void Dispose() } private string GenerateTaskId() => - GuidHelpers.CreateMonotonicId(GetUtcNow()); + IdHelpers.CreateMonotonicId(GetUtcNow()); private static bool IsTerminalStatus(McpTaskStatus status) => status is McpTaskStatus.Completed or McpTaskStatus.Failed or McpTaskStatus.Cancelled; diff --git a/tests/ModelContextProtocol.Tests/ModelContextProtocol.Tests.csproj b/tests/ModelContextProtocol.Tests/ModelContextProtocol.Tests.csproj index 491c9baac..7dafe3418 100644 --- a/tests/ModelContextProtocol.Tests/ModelContextProtocol.Tests.csproj +++ b/tests/ModelContextProtocol.Tests/ModelContextProtocol.Tests.csproj @@ -42,7 +42,7 @@ - + From 14d0d826b9a1c6adfe2f6f1daf875a95e947d773 Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Fri, 30 Jan 2026 16:48:57 -0500 Subject: [PATCH 24/24] Update src/ModelContextProtocol.Core/ModelContextProtocol.Core.csproj --- src/ModelContextProtocol.Core/ModelContextProtocol.Core.csproj | 1 - 1 file changed, 1 deletion(-) diff --git a/src/ModelContextProtocol.Core/ModelContextProtocol.Core.csproj b/src/ModelContextProtocol.Core/ModelContextProtocol.Core.csproj index 7780a5a83..bc4ae4c81 100644 --- a/src/ModelContextProtocol.Core/ModelContextProtocol.Core.csproj +++ b/src/ModelContextProtocol.Core/ModelContextProtocol.Core.csproj @@ -18,7 +18,6 @@ $(NoWarn);CS0436 - true