Implement MCP task support (SEP-1686) (#1170)

Co-authored-by: Stephen Halter <halter73@gmail.com>
This commit is contained in:
Eirik Tsarpalis 2026-01-28 05:21:02 +02:00 • committed by GitHub
commit 10e4f5b20f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -0,0 +1,504 @@
using ModelContextProtocol.Protocol;
using System.Collections.Concurrent;
using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
namespace ModelContextProtocol;
/// <summary>
/// Provides an in-memory implementation of <see cref="IMcpTaskStore"/> for development and testing.
/// </summary>
/// <remarks>
/// <para>
/// This implementation uses thread-safe concurrent collections and is suitable for single-server
/// scenarios and testing. It is not recommended for production multi-server deployments as tasks
/// are stored only in memory and are lost on server restart.
/// </para>
/// <para>
/// Features:
/// <list type="bullet">
/// <item><description>Thread-safe operations using <see cref="ConcurrentDictionary{TKey, TValue}"/></description></item>
/// <item><description>Automatic TTL-based cleanup via background task</description></item>
/// <item><description>Session-based isolation when sessionId is provided</description></item>
/// <item><description>Configurable default TTL and maximum TTL limits</description></item>
/// </list>
/// </para>
/// </remarks>
[Experimental(Experimentals.Tasks_DiagnosticId, UrlFormat = Experimentals.Tasks_Url)]
public sealed class InMemoryMcpTaskStore : IMcpTaskStore, IDisposable
{
private readonly ConcurrentDictionary<string, TaskEntry> _tasks = new();
private readonly TimeSpan? _defaultTtl;
private readonly TimeSpan? _maxTtl;
private readonly TimeSpan _pollInterval;
private readonly Timer? _cleanupTimer;
private readonly int _pageSize;
private readonly int? _maxTasks;
private readonly int? _maxTasksPerSession;
/// <summary>
/// Initializes a new instance of the <see cref="InMemoryMcpTaskStore"/> class.
/// </summary>
/// <param name="defaultTtl">
/// Default TTL to use when task creation does not specify a TTL. Null means unlimited.
/// </param>
/// <param name="maxTtl">
/// Maximum TTL allowed. If a task requests a longer TTL, it will be capped to this value.
/// Null means no maximum limit.
/// </param>
/// <param name="pollInterval">
/// Advertised polling interval for tasks. Default is 1 second.
/// This value is used when creating new tasks to indicate how frequently clients should poll for updates.
/// </param>
/// <param name="cleanupInterval">
/// Interval for running background cleanup of expired tasks. Default is 1 minute.
/// Pass <see cref="Timeout.InfiniteTimeSpan"/> to disable automatic cleanup.
/// </param>
/// <param name="pageSize">
/// Maximum number of tasks to return per page in <see cref="ListTasksAsync"/>. Default is 100.
/// </param>
/// <param name="maxTasks">
/// Maximum number of tasks allowed in the store globally. Null means unlimited.
/// When the limit is reached, <see cref="CreateTaskAsync"/> will throw <see cref="InvalidOperationException"/>.
/// </param>
/// <param name="maxTasksPerSession">
/// Maximum number of tasks allowed per session. Null means unlimited.
/// When the limit is reached for a session, <see cref="CreateTaskAsync"/> will throw <see cref="InvalidOperationException"/>.
/// </param>
public InMemoryMcpTaskStore(
TimeSpan? defaultTtl = null,
TimeSpan? maxTtl = null,
TimeSpan? pollInterval = null,
TimeSpan? cleanupInterval = null,
int pageSize = 100,
int? maxTasks = null,
int? maxTasksPerSession = null)
{
if (defaultTtl.HasValue && maxTtl.HasValue && defaultTtl.Value > maxTtl.Value)
{
throw new ArgumentException(
$"Default TTL ({defaultTtl.Value}) cannot exceed maximum TTL ({maxTtl.Value}).",
nameof(defaultTtl));
}
pollInterval ??= TimeSpan.FromSeconds(1);
if (pollInterval <= TimeSpan.Zero)
{
throw new ArgumentOutOfRangeException(
nameof(pollInterval),
pollInterval,
"Poll interval must be positive.");
}
if (pageSize <= 0)
{
throw new ArgumentOutOfRangeException(
nameof(pageSize),
pageSize,
"Page size must be positive.");
}
if (maxTasks is <= 0)
{
throw new ArgumentOutOfRangeException(
nameof(maxTasks),
maxTasks,
"Max tasks must be positive.");
}
if (maxTasksPerSession is <= 0)
{
throw new ArgumentOutOfRangeException(
nameof(maxTasksPerSession),
maxTasksPerSession,
"Max tasks per session must be positive.");
}
_defaultTtl = defaultTtl;
_maxTtl = maxTtl;
_pollInterval = pollInterval.Value;
_pageSize = pageSize;
_maxTasks = maxTasks;
_maxTasksPerSession = maxTasksPerSession;
cleanupInterval ??= TimeSpan.FromMinutes(1);
if (cleanupInterval.Value != Timeout.InfiniteTimeSpan)
{
_cleanupTimer = new Timer(CleanupExpiredTasks, null, cleanupInterval.Value, cleanupInterval.Value);
}
}
/// <inheritdoc/>
public Task<McpTask> CreateTaskAsync(
McpTaskMetadata taskParams,
RequestId requestId,
JsonRpcRequest request,
string? sessionId = null,
CancellationToken cancellationToken = default)
{
// Check global task limit
if (_maxTasks is { } maxTasks && _tasks.Count >= maxTasks)
{
throw new InvalidOperationException(
$"Maximum number of tasks ({maxTasks}) has been reached. Cannot create new task.");
}
// Check per-session task limit
if (_maxTasksPerSession is { } maxPerSession && sessionId is not null)
{
var sessionTaskCount = _tasks.Values.Count(e => e.SessionId == sessionId && !IsExpired(e));
if (sessionTaskCount >= maxPerSession)
{
throw new InvalidOperationException(
$"Maximum number of tasks per session ({maxPerSession}) has been reached for session '{sessionId}'. Cannot create new task.");
}
}
var taskId = GenerateTaskId();
var now = DateTimeOffset.UtcNow;
// Determine TTL: use requested, fall back to default, respect max limit
var ttl = taskParams.TimeToLive ?? _defaultTtl;
if (ttl is { } ttlValue && _maxTtl is { } maxTtlValue && ttlValue > maxTtlValue)
{
ttl = maxTtlValue;
}
TaskEntry entry = new()
{
TaskId = taskId,
Status = McpTaskStatus.Working,
CreatedAt = now,
LastUpdatedAt = now,
TimeToLive = ttl,
PollInterval = _pollInterval,
RequestId = requestId,
Request = request,
SessionId = sessionId
};
if (!_tasks.TryAdd(taskId, entry))
{
// This should be extremely rare with GUID-based IDs
throw new InvalidOperationException($"Task ID collision: {taskId}");
}
return Task.FromResult(entry.ToMcpTask());
}
/// <inheritdoc/>
public Task<McpTask?> GetTaskAsync(string taskId, string? sessionId = null, CancellationToken cancellationToken = default)
{
if (!_tasks.TryGetValue(taskId, out var entry))
{
return Task.FromResult<McpTask?>(null);
}
// Enforce session isolation if sessionId is provided
if (sessionId != null && entry.SessionId != sessionId)
{
return Task.FromResult<McpTask?>(null);
}
return Task.FromResult<McpTask?>(entry.ToMcpTask());
}
/// <inheritdoc/>
public Task<McpTask> StoreTaskResultAsync(
string taskId,
McpTaskStatus status,
JsonElement result,
string? sessionId = null,
CancellationToken cancellationToken = default)
{
if (status is not (McpTaskStatus.Completed or McpTaskStatus.Failed))
{
throw new ArgumentException(
$"Status must be {nameof(McpTaskStatus.Completed)} or {nameof(McpTaskStatus.Failed)}.",
nameof(status));
}
// Retry loop for optimistic concurrency
while (true)
{
if (!_tasks.TryGetValue(taskId, out var entry))
{
throw new InvalidOperationException($"Task not found: {taskId}");
}
// Enforce session isolation
if (sessionId != null && entry.SessionId != sessionId)
{
throw new InvalidOperationException($"Task not found: {taskId}");
}
// Prevent overwriting terminal state
if (IsTerminalStatus(entry.Status))
{
throw new InvalidOperationException(
$"Cannot store result for task in terminal state: {entry.Status}");
}
var updatedEntry = new TaskEntry(entry)
{
Status = status,
LastUpdatedAt = DateTimeOffset.UtcNow,
StoredResult = result
};
if (_tasks.TryUpdate(taskId, updatedEntry, entry))
{
return Task.FromResult(updatedEntry.ToMcpTask());
}
// Entry was modified by another thread, retry
}
}
/// <inheritdoc/>
public Task<JsonElement> GetTaskResultAsync(string taskId, string? sessionId = null, CancellationToken cancellationToken = default)
{
if (!_tasks.TryGetValue(taskId, out var entry))
{
throw new InvalidOperationException($"Task not found: {taskId}");
}
// Enforce session isolation
if (sessionId != entry.SessionId)
{
throw new InvalidOperationException($"Invalid sessionId: {sessionId} provided for {taskId}");
}
if (entry.StoredResult is not { } storedResult)
{
throw new InvalidOperationException($"No result stored for task: {taskId}");
}
return Task.FromResult(storedResult);
}
/// <inheritdoc/>
public Task<McpTask> UpdateTaskStatusAsync(
string taskId,
McpTaskStatus status,
string? statusMessage,
string? sessionId = null,
CancellationToken cancellationToken = default)
{
// Retry loop for optimistic concurrency
while (true)
{
if (!_tasks.TryGetValue(taskId, out var entry))
{
throw new InvalidOperationException($"Task not found: {taskId}");
}
// Enforce session isolation
if (sessionId != null && entry.SessionId != sessionId)
{
throw new InvalidOperationException($"Task not found: {taskId}");
}
var updatedEntry = new TaskEntry(entry)
{
Status = status,
StatusMessage = statusMessage,
LastUpdatedAt = DateTimeOffset.UtcNow,
};
if (_tasks.TryUpdate(taskId, updatedEntry, entry))
{
return Task.FromResult(updatedEntry.ToMcpTask());
}
// Entry was modified by another thread, retry
}
}
/// <inheritdoc/>
public Task<ListTasksResult> ListTasksAsync(
string? cursor = null,
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)
{
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();
// Set nextCursor if we have more results
string? nextCursor;
if (page.Count > _pageSize)
{
var lastItemInPage = page[_pageSize - 1]; // Last item we'll actually return
nextCursor = $"{lastItemInPage.CreatedAt:O}|{lastItemInPage.TaskId}";
page.RemoveAt(_pageSize); // Remove the extra item
}
else
{
nextCursor = null;
}
return Task.FromResult(new ListTasksResult
{
Tasks = page.ToArray(),
NextCursor = nextCursor
});
}
/// <inheritdoc/>
public Task<McpTask> CancelTaskAsync(string taskId, string? sessionId = null, CancellationToken cancellationToken = default)
{
// Retry loop for optimistic concurrency
while (true)
{
if (!_tasks.TryGetValue(taskId, out var entry))
{
throw new InvalidOperationException($"Task not found: {taskId}");
}
// Enforce session isolation
if (sessionId != null && entry.SessionId != sessionId)
{
throw new InvalidOperationException($"Task not found: {taskId}");
}
// If already in terminal state, return unchanged
if (IsTerminalStatus(entry.Status))
{
return Task.FromResult(entry.ToMcpTask());
}
var updatedEntry = new TaskEntry(entry)
{
Status = McpTaskStatus.Cancelled,
LastUpdatedAt = DateTimeOffset.UtcNow,
};
if (_tasks.TryUpdate(taskId, updatedEntry, entry))
{
return Task.FromResult(updatedEntry.ToMcpTask());
}
// Entry was modified by another thread, retry
}
}
/// <summary>
/// Disposes the task store and stops background cleanup.
/// </summary>
public void Dispose()
{
_cleanupTimer?.Dispose();
}
private static string GenerateTaskId() => Guid.NewGuid().ToString("N");
private static bool IsTerminalStatus(McpTaskStatus status) =>
status is McpTaskStatus.Completed or McpTaskStatus.Failed or McpTaskStatus.Cancelled;
private static bool IsExpired(TaskEntry entry)
{
if (entry.TimeToLive == null)
{
return false; // Unlimited lifetime
}
var expirationTime = entry.CreatedAt + entry.TimeToLive.Value;
return DateTimeOffset.UtcNow >= expirationTime;
}
private void CleanupExpiredTasks(object? state)
{
var expiredTaskIds = _tasks
.Where(kvp => IsExpired(kvp.Value))
.Select(kvp => kvp.Key)
.ToList();
foreach (var taskId in expiredTaskIds)
{
_tasks.TryRemove(taskId, out _);
}
}
private sealed class TaskEntry
{
// Flattened McpTask properties
public required string TaskId { get; init; }
public required McpTaskStatus Status { get; init; }
public string? StatusMessage { get; init; }
public required DateTimeOffset CreatedAt { get; init; }
public required DateTimeOffset LastUpdatedAt { get; init; }
public TimeSpan? TimeToLive { get; init; }
public TimeSpan? PollInterval { get; init; }
// Request metadata
public required RequestId RequestId { get; init; }
public required JsonRpcRequest Request { get; init; }
public required string? SessionId { get; init; }
public JsonElement? StoredResult { get; init; }
/// <summary>
/// Copy constructor for creating modified copies.
/// </summary>
[SetsRequiredMembers]
public TaskEntry(TaskEntry source)
{
TaskId = source.TaskId;
Status = source.Status;
StatusMessage = source.StatusMessage;
CreatedAt = source.CreatedAt;
LastUpdatedAt = source.LastUpdatedAt;
TimeToLive = source.TimeToLive;
PollInterval = source.PollInterval;
RequestId = source.RequestId;
Request = source.Request;
SessionId = source.SessionId;
StoredResult = source.StoredResult;
}
/// <summary>
/// Default constructor for initial creation.
/// </summary>
public TaskEntry() { }
/// <summary>
/// Converts this entry back to an McpTask for external consumption.
/// </summary>
public McpTask ToMcpTask() => new()
{
TaskId = TaskId,
Status = Status,
StatusMessage = StatusMessage,
CreatedAt = CreatedAt,
LastUpdatedAt = LastUpdatedAt,
TimeToLive = TimeToLive,
PollInterval = PollInterval
};
}
}