Skip to content

Commit d3b7fa1

Browse files
author
Tarek Mahmoud Sayed
committed
Honor task TTL in InMemoryMcpTaskStore
Discard tasks once their advertised time-to-live elapses: expired tasks are removed on access and a throttled opportunistic sweep reclaims expired tasks that are never polled again. A null or non-positive TimeToLive keeps tasks for the process lifetime, so the default behavior is unchanged.
1 parent 90c8a4d commit d3b7fa1

2 files changed

Lines changed: 112 additions & 5 deletions

File tree

src/ModelContextProtocol.Extensions.Tasks/Server/InMemoryMcpTaskStore.cs

Lines changed: 59 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -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>
2228
public 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;

tests/ModelContextProtocol.Tests/Server/InMemoryMcpTaskStoreTests.cs

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,59 @@ public async Task GetTaskAsync_ReturnsNullForUnknownId()
8888
Assert.Null(result);
8989
}
9090

91+
[Fact]
92+
public async Task GetTaskAsync_WithinTimeToLive_ReturnsTask()
93+
{
94+
var store = new InMemoryMcpTaskStore { DefaultTimeToLive = TimeSpan.FromMinutes(10) };
95+
var created = await store.CreateTaskAsync(CT);
96+
97+
var result = await store.GetTaskAsync(created.TaskId, CT);
98+
99+
Assert.NotNull(result);
100+
Assert.Equal(created.TaskId, result.TaskId);
101+
}
102+
103+
[Fact]
104+
public async Task GetTaskAsync_AfterTimeToLiveElapsed_ReturnsNull()
105+
{
106+
var store = new InMemoryMcpTaskStore { DefaultTimeToLive = TimeSpan.FromMilliseconds(100) };
107+
var created = await store.CreateTaskAsync(CT);
108+
109+
await Task.Delay(TimeSpan.FromMilliseconds(500), CT);
110+
111+
var result = await store.GetTaskAsync(created.TaskId, CT);
112+
113+
Assert.Null(result);
114+
}
115+
116+
[Fact]
117+
public async Task GetTaskAsync_WithoutTimeToLive_DoesNotExpire()
118+
{
119+
var store = new InMemoryMcpTaskStore();
120+
var created = await store.CreateTaskAsync(CT);
121+
122+
await Task.Delay(TimeSpan.FromMilliseconds(200), CT);
123+
124+
var result = await store.GetTaskAsync(created.TaskId, CT);
125+
126+
Assert.NotNull(result);
127+
Assert.Equal(created.TaskId, result.TaskId);
128+
}
129+
130+
[Fact]
131+
public async Task GetTaskAsync_WithZeroTimeToLive_DoesNotExpire()
132+
{
133+
var store = new InMemoryMcpTaskStore { DefaultTimeToLive = TimeSpan.Zero };
134+
var created = await store.CreateTaskAsync(CT);
135+
136+
await Task.Delay(TimeSpan.FromMilliseconds(200), CT);
137+
138+
var result = await store.GetTaskAsync(created.TaskId, CT);
139+
140+
Assert.NotNull(result);
141+
Assert.Equal(created.TaskId, result.TaskId);
142+
}
143+
91144
[Fact]
92145
public async Task SetCompletedAsync_TransitionsToCompleted()
93146
{

0 commit comments

Comments
 (0)