Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
40 changes: 40 additions & 0 deletions src/Common/Polyfills/System/IdHelpers.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
using System.Threading;

namespace System;

/// <summary>
/// Provides helper methods for monotonic ID generation.
/// </summary>
internal static class IdHelpers
{
private static long s_counter;

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

// Format as 32-character hex string (16 bytes = 128 bits)
// High 64 bits: timestamp ticks, Low 64 bits: counter
return $"{ticks:x16}{counter:x16}";
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
<PackageId>ModelContextProtocol.Core</PackageId>
<Description>Core .NET SDK for the Model Context Protocol (MCP)</Description>
<PackageReadmeFile>README.md</PackageReadmeFile>
<AllowUnsafeBlocks>True</AllowUnsafeBlocks>
<!-- Suppress the experimental tasks warning -->
<NoWarn>$(NoWarn);MCPEXP001</NoWarn>
</PropertyGroup>
Expand All @@ -19,6 +18,7 @@
<PropertyGroup Condition="'$(TargetFramework)' == 'netstandard2.0'">
<!-- CS0436: Allow ObsoleteAttribute to be redefined internally -->
<NoWarn>$(NoWarn);CS0436</NoWarn>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup>

<ItemGroup>
Expand Down
79 changes: 55 additions & 24 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

/// <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 All @@ -321,32 +351,22 @@ public Task<ListTasksResult> 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();
Expand All @@ -356,7 +376,7 @@ public Task<ListTasksResult> 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
Expand Down Expand Up @@ -397,7 +417,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 +437,31 @@ public void Dispose()
_cleanupTimer?.Dispose();
}

private static string GenerateTaskId() => Guid.NewGuid().ToString("N");
private string GenerateTaskId() =>
IdHelpers.CreateMonotonicId(GetUtcNow());

private static bool IsTerminalStatus(McpTaskStatus status) =>
status is McpTaskStatus.Completed or McpTaskStatus.Failed or McpTaskStatus.Cancelled;

#if MCP_TEST_TIME_PROVIDER
private DateTimeOffset GetUtcNow() => _timeProvider.GetUtcNow();
#else
private static DateTimeOffset GetUtcNow() => DateTimeOffset.UtcNow;
#endif

#if MCP_TEST_TIME_PROVIDER
private bool IsExpired(TaskEntry entry)
#else
private static bool IsExpired(TaskEntry entry)
#endif
{
if (entry.TimeToLive == null)
{
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
12 changes: 12 additions & 0 deletions tests/ModelContextProtocol.Tests/ModelContextProtocol.Tests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,15 @@
<TargetFrameworks>net10.0;net9.0;net8.0;net472</TargetFrameworks>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>

<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<RootNamespace>ModelContextProtocol.Tests</RootNamespace>
<!-- https://github.com/dotnet/sdk/issues/51060 -->
<NoWarn>$(NoWarn);NU1903;NU1902</NoWarn>
<!-- Define for conditional TimeProvider support in InMemoryMcpTaskStore -->
<DefineConstants>$(DefineConstants);MCP_TEST_TIME_PROVIDER</DefineConstants>
</PropertyGroup>

<PropertyGroup Condition="'$(TargetFramework)' == 'net9.0'">
Expand All @@ -27,13 +30,21 @@

<ItemGroup>
<Compile Include="..\Common\**\*.cs" />
<!-- Link InMemoryMcpTaskStore.cs for testing with TimeProvider support -->
<Compile Include="..\..\src\ModelContextProtocol.Core\Server\InMemoryMcpTaskStore.cs" Link="Server\InMemoryMcpTaskStore.cs" />
<!-- Include dependencies for the linked InMemoryMcpTaskStore.cs -->
<Compile Include="..\..\src\Common\Experimentals.cs" Link="Experimentals.cs" />
</ItemGroup>

<ItemGroup Condition="'$(TargetFramework)' == 'net472'">
<Compile Include="..\..\src\Common\Throw.cs" Link="Throw.cs" />
<Compile Include="..\..\src\Common\Polyfills\**\*.cs" />
</ItemGroup>

<ItemGroup Condition="'$(TargetFramework)' != 'net472'">
<Compile Include="..\..\src\Common\Polyfills\System\IdHelpers.cs" Link="Polyfills\IdHelpers.cs" />
</ItemGroup>

<ItemGroup>
<PackageReference Include="coverlet.collector">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
Expand All @@ -43,6 +54,7 @@
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" />
<PackageReference Include="Microsoft.Extensions.Logging" />
<PackageReference Include="Microsoft.Extensions.Logging.Console" />
<PackageReference Include="Microsoft.Extensions.TimeProvider.Testing" />
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="Moq" />
<PackageReference Include="OpenTelemetry" />
Expand Down
Loading
Loading