summaryrefslogtreecommitdiff
path: root/src/Agent.Mcp/PersistentMcpTaskStore.cs
diff options
context:
space:
mode:
Diffstat (limited to 'src/Agent.Mcp/PersistentMcpTaskStore.cs')
-rw-r--r--src/Agent.Mcp/PersistentMcpTaskStore.cs190
1 files changed, 190 insertions, 0 deletions
diff --git a/src/Agent.Mcp/PersistentMcpTaskStore.cs b/src/Agent.Mcp/PersistentMcpTaskStore.cs
new file mode 100644
index 0000000..73608d4
--- /dev/null
+++ b/src/Agent.Mcp/PersistentMcpTaskStore.cs
@@ -0,0 +1,190 @@
+using System.Collections.Concurrent;
+using System.Text.Json;
+using ModelContextProtocol.Extensions.Tasks;
+using ModelContextProtocol.Protocol;
+
+namespace Agent.Mcp;
+
+public sealed record McpTaskStoreSettings(string Directory, TimeSpan TimeToLive, long PollIntervalMs);
+
+public sealed class PersistentMcpTaskStore : IMcpTaskStore
+{
+ private readonly ConcurrentDictionary<CancellationToken, Guid> _taskIdsByCancellation = new();
+ private readonly ConcurrentDictionary<string, StoredTask> _tasks = new(StringComparer.Ordinal);
+ private readonly McpTaskStoreSettings _settings;
+ private readonly JsonSerializerOptions _json = new(JsonSerializerDefaults.Web) { WriteIndented = true };
+
+ public PersistentMcpTaskStore(McpTaskStoreSettings settings)
+ {
+ _settings = settings;
+ System.IO.Directory.CreateDirectory(settings.Directory);
+ Recover();
+ }
+
+ public event Action<InputResponseReceivedEventArgs>? InputResponseReceived;
+
+ public Guid? ResolveTaskId(CancellationToken cancellationToken)
+ {
+ if (!_taskIdsByCancellation.TryRemove(cancellationToken, out Guid taskId)) return null;
+ return taskId;
+ }
+
+ public async Task<McpTaskInfo> CreateTaskAsync(CancellationToken cancellationToken)
+ {
+ DateTimeOffset now = DateTimeOffset.UtcNow;
+ var task = new StoredTask(Guid.NewGuid().ToString("D"), McpTaskStatus.Working, now, now,
+ _settings.TimeToLive, _settings.PollIntervalMs, "working", null, null, null);
+ if (!_tasks.TryAdd(task.TaskId, task)) throw new InvalidOperationException("Could not allocate an MCP task ID.");
+ await SaveAsync(task, cancellationToken).ConfigureAwait(false);
+ Guid createdId = Guid.Parse(task.TaskId);
+ _taskIdsByCancellation[cancellationToken] = createdId;
+ return ToInfo(task);
+ }
+
+ public Task<McpTaskInfo?> GetTaskAsync(string taskId, CancellationToken cancellationToken)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ if (!_tasks.TryGetValue(taskId, out StoredTask? task)) return Task.FromResult<McpTaskInfo?>(null);
+ if (IsExpired(task))
+ {
+ _tasks.TryRemove(taskId, out _);
+ Delete(taskId);
+ return Task.FromResult<McpTaskInfo?>(null);
+ }
+ return Task.FromResult<McpTaskInfo?>(ToInfo(task));
+ }
+
+ public Task SetCompletedAsync(string taskId, JsonElement result, CancellationToken cancellationToken) =>
+ UpdateAsync(taskId, McpTaskStatus.Completed, "completed", result, null, null, cancellationToken);
+
+ public Task SetFailedAsync(string taskId, JsonElement error, CancellationToken cancellationToken) =>
+ UpdateAsync(taskId, McpTaskStatus.Failed, "failed", null, error, null, cancellationToken);
+
+ public async Task<bool> SetCancelledAsync(string taskId, CancellationToken cancellationToken)
+ {
+ if (!_tasks.ContainsKey(taskId)) return false;
+ await UpdateAsync(taskId, McpTaskStatus.Cancelled, "cancelled", null, null, null, cancellationToken).ConfigureAwait(false);
+ return true;
+ }
+
+ public async Task ResolveInputRequestsAsync(string taskId, IDictionary<string, InputResponse> responses, CancellationToken cancellationToken)
+ {
+ if (!_tasks.TryGetValue(taskId, out StoredTask? task)) return;
+ var pending = task.InputRequests is null
+ ? new Dictionary<string, InputRequest>(StringComparer.Ordinal)
+ : new Dictionary<string, InputRequest>(task.InputRequests, StringComparer.Ordinal);
+ var accepted = new List<KeyValuePair<string, InputResponse>>();
+ foreach ((string requestId, InputResponse response) in responses)
+ {
+ if (!pending.Remove(requestId)) continue;
+ accepted.Add(new KeyValuePair<string, InputResponse>(requestId, response));
+ }
+ await UpdateAsync(taskId, pending.Count == 0 ? McpTaskStatus.Working : McpTaskStatus.InputRequired,
+ pending.Count == 0 ? "working" : "input required", task.Result, task.Error,
+ pending.Count == 0 ? null : pending, cancellationToken).ConfigureAwait(false);
+ foreach ((string requestId, InputResponse response) in accepted)
+ InputResponseReceived?.Invoke(new InputResponseReceivedEventArgs { TaskId = taskId, RequestId = requestId, Response = response });
+ }
+
+ public Task SetInputRequestsAsync(string taskId, IDictionary<string, InputRequest> requests, CancellationToken cancellationToken) =>
+ UpdateAsync(taskId, McpTaskStatus.InputRequired, "input required", null, null,
+ new Dictionary<string, InputRequest>(requests, StringComparer.Ordinal), cancellationToken);
+
+ private async Task UpdateAsync(string taskId, McpTaskStatus status, string message, JsonElement? result,
+ JsonElement? error, IReadOnlyDictionary<string, InputRequest>? inputRequests, CancellationToken cancellationToken)
+ {
+ if (!_tasks.TryGetValue(taskId, out StoredTask? current)) throw new KeyNotFoundException($"MCP task '{taskId}' was not found.");
+ var updated = current with
+ {
+ Status = status,
+ LastUpdatedAt = DateTimeOffset.UtcNow,
+ StatusMessage = message,
+ Result = result,
+ Error = error,
+ InputRequests = inputRequests
+ };
+ _tasks[taskId] = updated;
+ await SaveAsync(updated, cancellationToken).ConfigureAwait(false);
+ }
+
+ private void Recover()
+ {
+ foreach (string path in System.IO.Directory.EnumerateFiles(_settings.Directory, "*.json", SearchOption.TopDirectoryOnly))
+ {
+ try
+ {
+ StoredTask? task = JsonSerializer.Deserialize<StoredTask>(File.ReadAllText(path), _json);
+ if (task is null || IsExpired(task)) { File.Delete(path); continue; }
+ if (task.Status is McpTaskStatus.Working or McpTaskStatus.InputRequired)
+ {
+ JsonElement error = JsonSerializer.SerializeToElement(new { code = "server_restarted", message = "The server restarted before the task completed." });
+ task = task with { Status = McpTaskStatus.Failed, StatusMessage = "server_restarted", Error = error, LastUpdatedAt = DateTimeOffset.UtcNow, InputRequests = null };
+ SaveSynchronously(task);
+ }
+ _tasks[task.TaskId] = task;
+ }
+ catch (JsonException)
+ {
+ File.Move(path, path + ".invalid", true);
+ }
+ }
+ }
+
+ private bool IsExpired(StoredTask task) => task.TimeToLive is { } ttl && DateTimeOffset.UtcNow - task.CreatedAt >= ttl;
+
+ private async ValueTask SaveAsync(StoredTask task, CancellationToken cancellationToken)
+ {
+ string path = PathFor(task.TaskId);
+ string temp = path + ".tmp-" + Guid.NewGuid().ToString("N");
+ try
+ {
+ await using (var stream = new FileStream(temp, FileMode.CreateNew, FileAccess.Write, FileShare.None, 4096,
+ FileOptions.Asynchronous | FileOptions.WriteThrough))
+ {
+ await JsonSerializer.SerializeAsync(stream, task, _json, cancellationToken).ConfigureAwait(false);
+ await stream.FlushAsync(cancellationToken).ConfigureAwait(false);
+ }
+ File.Move(temp, path, true);
+ }
+ finally { if (File.Exists(temp)) File.Delete(temp); }
+ }
+
+ private void SaveSynchronously(StoredTask task)
+ {
+ string path = PathFor(task.TaskId);
+ string temp = path + ".tmp-" + Guid.NewGuid().ToString("N");
+ try
+ {
+ using (var stream = new FileStream(temp, FileMode.CreateNew, FileAccess.Write, FileShare.None, 4096, FileOptions.WriteThrough))
+ {
+ JsonSerializer.Serialize(stream, task, _json);
+ stream.Flush(true);
+ }
+ File.Move(temp, path, true);
+ }
+ finally { if (File.Exists(temp)) File.Delete(temp); }
+ }
+
+ private void Delete(string taskId)
+ {
+ string path = PathFor(taskId);
+ if (File.Exists(path)) File.Delete(path);
+ }
+
+ private string PathFor(string taskId) => Path.Combine(_settings.Directory, taskId + ".json");
+
+ private static McpTaskInfo ToInfo(StoredTask task) => new(task.TaskId, task.Status, task.CreatedAt, task.LastUpdatedAt,
+ task.TimeToLive, task.PollIntervalMs, task.StatusMessage, task.Result, task.Error, task.InputRequests);
+
+ private sealed record StoredTask(
+ string TaskId,
+ McpTaskStatus Status,
+ DateTimeOffset CreatedAt,
+ DateTimeOffset LastUpdatedAt,
+ TimeSpan? TimeToLive,
+ long? PollIntervalMs,
+ string StatusMessage,
+ JsonElement? Result,
+ JsonElement? Error,
+ IReadOnlyDictionary<string, InputRequest>? InputRequests);
+}