@@ -15,13 +15,21 @@ namespace ModelContextProtocol.Extensions.Tasks;
1515/// Tasks are not persisted across process restarts.
1616/// </para>
1717/// <para>
18- /// For production scenarios requiring durability, session isolation, or TTL-based cleanup,
19- /// implement a custom <see cref="IMcpTaskStore"/>.
18+ /// Tasks created with a <see cref="DefaultTimeToLive"/> are discarded once their time-to-live
19+ /// elapses (as permitted by SEP-2663): an expired task is removed on access, and an opportunistic
20+ /// throttled sweep reclaims expired tasks that are never polled again. Tasks created without a
21+ /// time-to-live are retained until the process exits.
22+ /// </para>
23+ /// <para>
24+ /// For production scenarios requiring durability, session isolation, or more advanced retention
25+ /// policies, implement a custom <see cref="IMcpTaskStore"/>.
2026/// </para>
2127/// </remarks>
2228public class InMemoryMcpTaskStore : IMcpTaskStore
2329{
2430 private readonly ConcurrentDictionary < string , McpTaskInfo > _tasks = new ( ) ;
31+ private static readonly long s_sweepIntervalTicks = TimeSpan . FromSeconds ( 30 ) . Ticks ;
32+ private long _lastSweepTicks = DateTimeOffset . UtcNow . UtcTicks ;
2533
2634 /// <summary>
2735 /// Gets or sets the default poll interval in milliseconds for new tasks.
@@ -32,13 +40,19 @@ public class InMemoryMcpTaskStore : IMcpTaskStore
3240 /// <summary>
3341 /// Gets or sets the default time-to-live for new tasks, or <see langword="null"/> for unlimited.
3442 /// </summary>
43+ /// <remarks>
44+ /// When set to a positive value, tasks are discarded once this duration elapses from their
45+ /// creation. A <see langword="null"/> or non-positive value keeps tasks until the process exits.
46+ /// </remarks>
3547 public TimeSpan ? DefaultTimeToLive { get ; set ; }
3648
3749 /// <inheritdoc/>
3850 public Task < McpTaskInfo > CreateTaskAsync ( CancellationToken cancellationToken = default )
3951 {
40- var taskId = Guid . NewGuid ( ) . ToString ( "N" ) ;
4152 var now = DateTimeOffset . UtcNow ;
53+ SweepExpired ( now ) ;
54+
55+ var taskId = Guid . NewGuid ( ) . ToString ( "N" ) ;
4256
4357 var info = new McpTaskInfo ( taskId , McpTaskStatus . Working , now , now , DefaultTimeToLive , DefaultPollIntervalMs ) ;
4458 _tasks [ taskId ] = info ;
@@ -49,8 +63,19 @@ public Task<McpTaskInfo> CreateTaskAsync(CancellationToken cancellationToken = d
4963 /// <inheritdoc/>
5064 public Task < McpTaskInfo ? > GetTaskAsync ( string taskId , CancellationToken cancellationToken = default )
5165 {
52- _tasks . TryGetValue ( taskId , out var info ) ;
53- return Task . FromResult < McpTaskInfo ? > ( info ) ;
66+ var now = DateTimeOffset . UtcNow ;
67+ if ( _tasks . TryGetValue ( taskId , out var info ) )
68+ {
69+ if ( IsExpired ( info , now ) )
70+ {
71+ _tasks . TryRemove ( taskId , out _ ) ;
72+ return Task . FromResult < McpTaskInfo ? > ( null ) ;
73+ }
74+
75+ return Task . FromResult < McpTaskInfo ? > ( info ) ;
76+ }
77+
78+ return Task . FromResult < McpTaskInfo ? > ( null ) ;
5479 }
5580
5681 /// <inheritdoc/>
@@ -198,6 +223,35 @@ public Task SetInputRequestsAsync(
198223 private static bool IsTerminal ( McpTaskStatus status ) =>
199224 status is McpTaskStatus . Completed or McpTaskStatus . Failed or McpTaskStatus . Cancelled ;
200225
226+ private static bool IsExpired ( McpTaskInfo info , DateTimeOffset now ) =>
227+ info . TimeToLive is { } ttl && ttl > TimeSpan . Zero && now - info . CreatedAt >= ttl ;
228+
229+ private void SweepExpired ( DateTimeOffset now )
230+ {
231+ long last = Interlocked . Read ( ref _lastSweepTicks ) ;
232+ if ( now . UtcTicks - last < s_sweepIntervalTicks )
233+ {
234+ return ;
235+ }
236+
237+ // Ensure only one caller runs the sweep per interval; concurrent callers skip it.
238+ if ( Interlocked . CompareExchange ( ref _lastSweepTicks , now . UtcTicks , last ) != last )
239+ {
240+ return ;
241+ }
242+
243+ foreach ( var kvp in _tasks )
244+ {
245+ if ( IsExpired ( kvp . Value , now ) )
246+ {
247+ // TaskId values are unique GUIDs that are never reused, and CreatedAt/TimeToLive
248+ // are immutable after creation, so an entry judged expired here stays expired.
249+ // Removing by key is therefore safe even if the value was concurrently updated.
250+ _tasks . TryRemove ( kvp . Key , out _ ) ;
251+ }
252+ }
253+ }
254+
201255 private void Update ( string taskId , Func < McpTaskInfo , McpTaskInfo > transform )
202256 {
203257 SpinWait spin = default ;
0 commit comments