33using System . Diagnostics . CodeAnalysis ;
44using System . Text . Json ;
55
6+ #if MCP_TEST_TIME_PROVIDER
7+ namespace ModelContextProtocol . Tests . Internal ;
8+ #else
69namespace ModelContextProtocol ;
10+ #endif
711
812/// <summary>
913/// Provides an in-memory implementation of <see cref="IMcpTaskStore"/> for development and testing.
@@ -35,6 +39,9 @@ public sealed class InMemoryMcpTaskStore : IMcpTaskStore, IDisposable
3539 private readonly int _pageSize ;
3640 private readonly int ? _maxTasks ;
3741 private readonly int ? _maxTasksPerSession ;
42+ #if MCP_TEST_TIME_PROVIDER
43+ private readonly TimeProvider _timeProvider ;
44+ #endif
3845
3946 /// <summary>
4047 /// Initializes a new instance of the <see cref="InMemoryMcpTaskStore"/> class.
@@ -120,6 +127,9 @@ public InMemoryMcpTaskStore(
120127 _pageSize = pageSize ;
121128 _maxTasks = maxTasks ;
122129 _maxTasksPerSession = maxTasksPerSession ;
130+ #if MCP_TEST_TIME_PROVIDER
131+ _timeProvider = TimeProvider . System ;
132+ #endif
123133
124134 cleanupInterval ??= TimeSpan . FromMinutes ( 1 ) ;
125135 if ( cleanupInterval . Value != Timeout . InfiniteTimeSpan )
@@ -128,6 +138,26 @@ public InMemoryMcpTaskStore(
128138 }
129139 }
130140
141+ #if MCP_TEST_TIME_PROVIDER
142+ /// <summary>
143+ /// Initializes a new instance of the <see cref="InMemoryMcpTaskStore"/> class with a custom time provider.
144+ /// This constructor is only available for testing purposes.
145+ /// </summary>
146+ internal InMemoryMcpTaskStore (
147+ TimeSpan ? defaultTtl ,
148+ TimeSpan ? maxTtl ,
149+ TimeSpan ? pollInterval ,
150+ TimeSpan ? cleanupInterval ,
151+ int pageSize ,
152+ int ? maxTasks ,
153+ int ? maxTasksPerSession ,
154+ TimeProvider timeProvider )
155+ : this ( defaultTtl , maxTtl , pollInterval , cleanupInterval , pageSize , maxTasks , maxTasksPerSession )
156+ {
157+ _timeProvider = timeProvider ?? TimeProvider . System ;
158+ }
159+ #endif
160+
131161 /// <inheritdoc/>
132162 public Task < McpTask > CreateTaskAsync (
133163 McpTaskMetadata taskParams ,
@@ -155,7 +185,7 @@ public Task<McpTask> CreateTaskAsync(
155185 }
156186
157187 var taskId = GenerateTaskId ( ) ;
158- var now = DateTimeOffset . UtcNow ;
188+ var now = GetUtcNow ( ) ;
159189
160190 // Determine TTL: use requested, fall back to default, respect max limit
161191 var ttl = taskParams . TimeToLive ?? _defaultTtl ;
@@ -242,7 +272,7 @@ public Task<McpTask> StoreTaskResultAsync(
242272 var updatedEntry = new TaskEntry ( entry )
243273 {
244274 Status = status ,
245- LastUpdatedAt = DateTimeOffset . UtcNow ,
275+ LastUpdatedAt = GetUtcNow ( ) ,
246276 StoredResult = result
247277 } ;
248278
@@ -303,7 +333,7 @@ public Task<McpTask> UpdateTaskStatusAsync(
303333 {
304334 Status = status ,
305335 StatusMessage = statusMessage ,
306- LastUpdatedAt = DateTimeOffset . UtcNow ,
336+ LastUpdatedAt = GetUtcNow ( ) ,
307337 } ;
308338
309339 if ( _tasks . TryUpdate ( taskId , updatedEntry , entry ) )
@@ -321,32 +351,22 @@ public Task<ListTasksResult> ListTasksAsync(
321351 string ? sessionId = null ,
322352 CancellationToken cancellationToken = default )
323353 {
324- // Parse cursor: format is "CreatedAt|TaskId" for keyset pagination
325- ( DateTimeOffset , string ) ? parsedCursor = null ;
326- if ( cursor != null )
327- {
328- var parts = cursor . Split ( '|' ) ;
329- if ( parts . Length == 2 &&
330- DateTimeOffset . TryParse ( parts [ 0 ] , out var parsedDate ) )
331- {
332- parsedCursor = ( parsedDate , parts [ 1 ] ) ;
333- }
334- }
335-
336354 // Stream enumeration - filter by session, exclude expired, apply keyset pagination
337355 var query = _tasks . Values
338356 . Where ( e => sessionId == null || e . SessionId == sessionId )
339357 . Where ( e => ! IsExpired ( e ) ) ;
340358
341- // Apply keyset filter if cursor provided: (CreatedAt, TaskId) > cursor
342- if ( parsedCursor is { } parsedCursorValue )
359+ // Apply keyset filter if cursor provided: TaskId > cursor
360+ // UUID v7 task IDs are monotonically increasing and inherently time-ordered
361+ if ( cursor != null )
343362 {
344- query = query . Where ( e => ( e . CreatedAt , e . TaskId ) . CompareTo ( parsedCursorValue ) > 0 ) ;
363+ query = query . Where ( e => string . CompareOrdinal ( e . TaskId , cursor ) > 0 ) ;
345364 }
346365
347- // Order by (CreatedAt, TaskId) for stable, deterministic pagination
366+ // Order by TaskId for stable, deterministic pagination
367+ // UUID v7 task IDs sort chronologically due to embedded timestamp
348368 var page = query
349- . OrderBy ( e => ( e . CreatedAt , e . TaskId ) )
369+ . OrderBy ( e => e . TaskId , StringComparer . Ordinal )
350370 . Take ( _pageSize + 1 ) // Take one extra to check if there's a next page
351371 . Select ( e => e . ToMcpTask ( ) )
352372 . ToList ( ) ;
@@ -356,7 +376,7 @@ public Task<ListTasksResult> ListTasksAsync(
356376 if ( page . Count > _pageSize )
357377 {
358378 var lastItemInPage = page [ _pageSize - 1 ] ; // Last item we'll actually return
359- nextCursor = $ " { lastItemInPage . CreatedAt : O } | { lastItemInPage . TaskId } " ;
379+ nextCursor = lastItemInPage . TaskId ;
360380 page . RemoveAt ( _pageSize ) ; // Remove the extra item
361381 }
362382 else
@@ -397,7 +417,7 @@ public Task<McpTask> CancelTaskAsync(string taskId, string? sessionId = null, Ca
397417 var updatedEntry = new TaskEntry ( entry )
398418 {
399419 Status = McpTaskStatus . Cancelled ,
400- LastUpdatedAt = DateTimeOffset . UtcNow ,
420+ LastUpdatedAt = GetUtcNow ( ) ,
401421 } ;
402422
403423 if ( _tasks . TryUpdate ( taskId , updatedEntry , entry ) )
@@ -417,20 +437,31 @@ public void Dispose()
417437 _cleanupTimer ? . Dispose ( ) ;
418438 }
419439
420- private static string GenerateTaskId ( ) => Guid . NewGuid ( ) . ToString ( "N" ) ;
440+ private string GenerateTaskId ( ) =>
441+ IdHelpers . CreateMonotonicId ( GetUtcNow ( ) ) ;
421442
422443 private static bool IsTerminalStatus ( McpTaskStatus status ) =>
423444 status is McpTaskStatus . Completed or McpTaskStatus . Failed or McpTaskStatus . Cancelled ;
424445
446+ #if MCP_TEST_TIME_PROVIDER
447+ private DateTimeOffset GetUtcNow ( ) => _timeProvider . GetUtcNow ( ) ;
448+ #else
449+ private static DateTimeOffset GetUtcNow ( ) => DateTimeOffset . UtcNow ;
450+ #endif
451+
452+ #if MCP_TEST_TIME_PROVIDER
453+ private bool IsExpired ( TaskEntry entry )
454+ #else
425455 private static bool IsExpired ( TaskEntry entry )
456+ #endif
426457 {
427458 if ( entry . TimeToLive == null )
428459 {
429460 return false ; // Unlimited lifetime
430461 }
431462
432463 var expirationTime = entry . CreatedAt + entry . TimeToLive . Value ;
433- return DateTimeOffset . UtcNow >= expirationTime ;
464+ return GetUtcNow ( ) >= expirationTime ;
434465 }
435466
436467 private void CleanupExpiredTasks ( object ? state )
0 commit comments