From 788df5e1d86c1bd91571d2b9cd1559599b65c9d6 Mon Sep 17 00:00:00 2001 From: tanaka takashi Date: Sun, 9 Aug 2026 23:19:24 +0900 Subject: feat(mcp): migrate server to MCP 2026-07-28 Replace the MCP 1.4.1 stateful HTTP design with the official C# SDK 2.0.0 stateless transport. The SDK now owns protocol negotiation, server/discover, standard MCP headers, GET and DELETE handling, error serialization, caching hints, and legacy initialize compatibility; production code no longer maintains a custom wire-protocol workaround for MCP Inspector. Separate transport state from application conversation state. HTTP does not issue Mcp-Session-Id, while the existing session_id remains an Agent conversation identifier. Preserve agent_run and agent_status for clients without Tasks support and add the official ModelContextProtocol.Extensions.Tasks 2.0.0 path for capable clients. Add persistent MCP Task storage with atomic JSON replacement, user-only state location, configurable TTL and poll interval, restart recovery, cancellation correlation, and public Agent-to-MCP state mapping. Keep conformance-only tools and protocol exception fixtures in a test host so they cannot enter the Release Bundle. Run administrator, BFE, WFP transaction, and filter add/delete preflight before binding. Fail fast with exit code 2 and actionable localized diagnostics when sandbox enforcement is unavailable. Emit ready only after the listener starts and keep stdio stdout protocol-pure. Add shared rotating application and HTTP request logs. Record real socket endpoints, connection and trace identifiers, method, path, result, timing, media metadata, User-Agent, and MCP metadata. Do not trust X-Forwarded-For and do not log authorization, cookies, query data, request or response bodies, prompts, images, or Base64 payloads. Add typed logging and Tasks configuration, Japanese and English messages, protocol and operations documentation, design/specification deltas, traceability, and exact conformance acquisition/offline runner scripts for @modelcontextprotocol/conformance 0.2.0-alpha.11. Node remains test-only and is not a runtime dependency. Conformance evidence: server-stateless passed 28 of 28 checks with zero unexpected failures; DNS rebinding and multiple POST stream scenarios passed. The alpha Tasks lifecycle/cancel/error/required-task checks passed, while its wire-schema validator misclassifies CreateTaskResult and its MRTR composition remains incompatible with SDK 2.0.0. The aggregate conformance Gate is therefore recorded as PARTIAL, not PASS. Test-host fixture gaps for tools-list descriptions, JSON Schema diagnostics, caching fixtures, and a header-validation/required-Task capability conflict are also recorded without expected-failure masking. Verification: Release build passed with 0 warnings and 0 errors; Planner tests 10/10 and Core tests 47/47 passed with skip 0. The last Windows suite run passed 19/25; five WFP cases require an elevated token and one symlink case requires unavailable host privilege, so the elevated security Gate remains open. A win-x64 self-contained 1.0.0 Bundle and ZIP were regenerated under build/Release/publish; runtime check was intentionally skipped in this non-elevated session. Evidence remains under ignored artifacts/evidence/mcp-2026-07-28. Harden release regeneration when another shell holds the previous Bundle directory as its current directory: validate the target and remove only its children instead of deleting the directory itself. build/ and artifacts/ remain excluded from Git. --- src/Agent.Mcp/PersistentMcpTaskStore.cs | 190 ++++++++++++++++++++++++++++++++ 1 file changed, 190 insertions(+) create mode 100644 src/Agent.Mcp/PersistentMcpTaskStore.cs (limited to 'src/Agent.Mcp/PersistentMcpTaskStore.cs') 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 _taskIdsByCancellation = new(); + private readonly ConcurrentDictionary _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? InputResponseReceived; + + public Guid? ResolveTaskId(CancellationToken cancellationToken) + { + if (!_taskIdsByCancellation.TryRemove(cancellationToken, out Guid taskId)) return null; + return taskId; + } + + public async Task 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 GetTaskAsync(string taskId, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + if (!_tasks.TryGetValue(taskId, out StoredTask? task)) return Task.FromResult(null); + if (IsExpired(task)) + { + _tasks.TryRemove(taskId, out _); + Delete(taskId); + return Task.FromResult(null); + } + return Task.FromResult(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 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 responses, CancellationToken cancellationToken) + { + if (!_tasks.TryGetValue(taskId, out StoredTask? task)) return; + var pending = task.InputRequests is null + ? new Dictionary(StringComparer.Ordinal) + : new Dictionary(task.InputRequests, StringComparer.Ordinal); + var accepted = new List>(); + foreach ((string requestId, InputResponse response) in responses) + { + if (!pending.Remove(requestId)) continue; + accepted.Add(new KeyValuePair(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 requests, CancellationToken cancellationToken) => + UpdateAsync(taskId, McpTaskStatus.InputRequired, "input required", null, null, + new Dictionary(requests, StringComparer.Ordinal), cancellationToken); + + private async Task UpdateAsync(string taskId, McpTaskStatus status, string message, JsonElement? result, + JsonElement? error, IReadOnlyDictionary? 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(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? InputRequests); +} -- cgit v1.2.3