diff options
| author | tanaka takashi <isazaka@gmail.com> | 2026-08-09 23:19:24 +0900 |
|---|---|---|
| committer | tanaka takashi <isazaka@gmail.com> | 2026-08-09 23:19:24 +0900 |
| commit | 788df5e1d86c1bd91571d2b9cd1559599b65c9d6 (patch) | |
| tree | 3d1d9bec9e8318bfc6749f9f8f1727436e3d6af1 /tests/Agent.Core.Tests | |
| parent | b07b41cd435991cd254957af79055080c85b346e (diff) | |
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.
Diffstat (limited to 'tests/Agent.Core.Tests')
| -rw-r--r-- | tests/Agent.Core.Tests/Agent.Core.Tests.csproj | 1 | ||||
| -rw-r--r-- | tests/Agent.Core.Tests/McpHttpTests.cs | 28 | ||||
| -rw-r--r-- | tests/Agent.Core.Tests/McpStdioTests.cs | 35 | ||||
| -rw-r--r-- | tests/Agent.Core.Tests/ObservabilityTests.cs | 46 | ||||
| -rw-r--r-- | tests/Agent.Core.Tests/PersistentMcpTaskStoreTests.cs | 36 | ||||
| -rw-r--r-- | tests/Agent.Core.Tests/SharedFakeOllamaServer.cs | 55 | ||||
| -rw-r--r-- | tests/Agent.Core.Tests/TaskRegistryTests.cs | 13 |
7 files changed, 186 insertions, 28 deletions
diff --git a/tests/Agent.Core.Tests/Agent.Core.Tests.csproj b/tests/Agent.Core.Tests/Agent.Core.Tests.csproj index 1326733..337bde9 100644 --- a/tests/Agent.Core.Tests/Agent.Core.Tests.csproj +++ b/tests/Agent.Core.Tests/Agent.Core.Tests.csproj @@ -8,6 +8,7 @@ <ItemGroup> <ProjectReference Include="..\..\src\Agent.Core\Agent.Core.csproj" /> <ProjectReference Include="..\..\src\Agent.App\Agent.App.csproj" /> + <ProjectReference Include="..\Agent.Mcp.TestHost\Agent.Mcp.TestHost.csproj" /> </ItemGroup> <ItemGroup> <PackageReference Include="Microsoft.NET.Test.Sdk" /> diff --git a/tests/Agent.Core.Tests/McpHttpTests.cs b/tests/Agent.Core.Tests/McpHttpTests.cs index 7e3f1cf..9edb133 100644 --- a/tests/Agent.Core.Tests/McpHttpTests.cs +++ b/tests/Agent.Core.Tests/McpHttpTests.cs @@ -6,6 +6,9 @@ using Agent.Core.Runtime; using Agent.Mcp; using ModelContextProtocol; using ModelContextProtocol.Client; +using ModelContextProtocol.Extensions.Tasks; +using ModelContextProtocol.Protocol; +using System.Text.Json; namespace Agent.Core.Tests; @@ -15,9 +18,10 @@ public sealed class McpHttpTests public async Task Loopback_streamable_http_works_and_get_sse_legacy_sse_are_rejected() { int port = FreePort(); + await using var ollamaServer = new SharedFakeOllamaServer(); using var stop = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); LocaleCatalog locale = LocaleCatalog.Load(Path.GetFullPath("config/locales"), "en"); - var settings = new OllamaSettings(new Uri("http://127.0.0.1:1"), "unused", false, "0", new Dictionary<string, System.Text.Json.JsonElement>()); + var settings = new OllamaSettings(ollamaServer.Endpoint, "unused", false, "0", new Dictionary<string, System.Text.Json.JsonElement>()); Task server = McpHost.RunHttpAsync(locale, settings, new AgentRuntimeSettings("", "en", [], "sandbox"), new VisionLimits(), "127.0.0.1", port, stop.Token); using var http = new HttpClient(); Uri endpoint = new($"http://127.0.0.1:{port}/mcp"); @@ -33,10 +37,32 @@ public sealed class McpHttpTests { IList<McpClientTool> tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); Assert.Equal(["agent_run", "agent_status"], tools.Select(static tool => tool.Name).Order(StringComparer.Ordinal)); + + ResultOrCreatedTask<CallToolResult> taskCall = await client.CallToolAsTaskAsync(new CallToolRequestParams + { + Name = "agent_run", + Arguments = new Dictionary<string, JsonElement> { ["request"] = JsonSerializer.SerializeToElement("task extension test") } + }, TestContext.Current.CancellationToken); + Assert.True(taskCall.IsTask); + CreateTaskResult created = taskCall.TaskCreated!; + Assert.True(Guid.TryParse(created.TaskId, out _)); + GetTaskResult? task = null; + for (int attempt = 0; attempt < 400; attempt++) + { + task = await client.GetTaskAsync(created.TaskId, TestContext.Current.CancellationToken); + if (task.Status is McpTaskStatus.Completed or McpTaskStatus.Failed or McpTaskStatus.Cancelled) break; + await Task.Delay(25, TestContext.Current.CancellationToken); + } + Assert.NotNull(task); + Assert.Equal(McpTaskStatus.Completed, task.Status); + Assert.Contains(created.TaskId, JsonSerializer.Serialize(task), StringComparison.OrdinalIgnoreCase); } using HttpResponseMessage get = await http.GetAsync(endpoint, TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.MethodNotAllowed, get.StatusCode); + Assert.False(get.Headers.Contains("Mcp-Session-Id")); + using HttpResponseMessage delete = await http.DeleteAsync(endpoint, TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.MethodNotAllowed, delete.StatusCode); using var hostileOrigin = new HttpRequestMessage(HttpMethod.Post, endpoint); hostileOrigin.Headers.Add("Origin", "https://example.invalid"); using HttpResponseMessage forbidden = await http.SendAsync(hostileOrigin, TestContext.Current.CancellationToken); diff --git a/tests/Agent.Core.Tests/McpStdioTests.cs b/tests/Agent.Core.Tests/McpStdioTests.cs index 66f4851..fcef80e 100644 --- a/tests/Agent.Core.Tests/McpStdioTests.cs +++ b/tests/Agent.Core.Tests/McpStdioTests.cs @@ -1,4 +1,4 @@ -using Agent.App.Configuration; +using Agent.Mcp.TestHost; using ModelContextProtocol; using ModelContextProtocol.Client; using ModelContextProtocol.Protocol; @@ -15,17 +15,18 @@ public sealed class McpStdioTests { using var directory = new TestDirectory(); await using var ollama = new FakeOllamaServer(); - string configPath = CreateBundleConfiguration(directory, ollama.Endpoint); - string application = typeof(ConfigurationLoader).Assembly.Location; + string localeDirectory = CreateLocale(directory); + string application = typeof(Marker).Assembly.Location; var transport = new StdioClientTransport(new StdioClientTransportOptions { Name = "agent-mcp-test", Command = "dotnet", - Arguments = [application, "serve", "--config", configPath, "--lang", "en"], + Arguments = [application, localeDirectory, System.IO.Path.Combine(directory.Path, "mcp-tasks")], WorkingDirectory = directory.Path }); - await using McpClient client = await McpClient.CreateAsync(transport, cancellationToken: TestContext.Current.CancellationToken); + await using McpClient client = await McpClient.CreateAsync(transport, + new McpClientOptions { ProtocolVersion = "2025-11-25" }, cancellationToken: TestContext.Current.CancellationToken); IList<McpClientTool> tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); Assert.Equal(["agent_run", "agent_status"], tools.Select(static tool => tool.Name).Order(StringComparer.Ordinal)); foreach (McpClientTool tool in tools) @@ -71,30 +72,10 @@ public sealed class McpStdioTests return Guid.Parse(line[(key.Length + 1)..].Trim()); } - private static string CreateBundleConfiguration(TestDirectory directory, Uri endpoint) + private static string CreateLocale(TestDirectory directory) { - directory.File("prompts/user-system.md", string.Empty); directory.File("config/locales/en.json", """{"app.starting":"Starting","check.success":"OK","check.failure":"Failed {message}","check.wfp_admin_required":"Administrator required","config.invalid":"Invalid {message}","locale.invalid":"Locale {message}","mcp.task_queued":"Task {task} queued in {session}","mcp.task_not_found":"Task {task} not found","sandbox.access_denied":"Denied {path}","ollama.vision_unsupported":"No vision"}"""); - Directory.CreateDirectory(System.IO.Path.Combine(directory.Path, "skills")); - return directory.File("agent.yaml", $$""" - schema_version: 1 - application: - language: en - mcp: - transport: stdio - agent: - user_system_prompt: prompts/user-system.md - ollama: - endpoint: {{endpoint}} - model: model - localization: - directory: config/locales - fallback: en - skills: - directory: skills - logging: - directory: logs - """); + return System.IO.Path.Combine(directory.Path, "config", "locales"); } private sealed class FakeOllamaServer : IAsyncDisposable diff --git a/tests/Agent.Core.Tests/ObservabilityTests.cs b/tests/Agent.Core.Tests/ObservabilityTests.cs new file mode 100644 index 0000000..cd86bc2 --- /dev/null +++ b/tests/Agent.Core.Tests/ObservabilityTests.cs @@ -0,0 +1,46 @@ +using Agent.App.Logging; +using Agent.Core.Localization; +using Agent.Mcp; + +namespace Agent.Core.Tests; + +public sealed class ObservabilityTests +{ + [Fact] + public void Ready_is_always_visible_even_when_log_threshold_is_fatal() + { + using var directory = new TestDirectory(); + LocaleCatalog locale = LocaleCatalog.Load(Path.GetFullPath("config/locales"), "en"); + using (var application = new RollingFileLog(directory.Path, 1024 * 1024, 2, "fatal")) + using (var observer = new McpHostLogObserver(application, locale, directory.Path, false, 1024 * 1024, 2)) + observer.Ready(new McpReadyInfo("http", "http://127.0.0.1:8080/mcp", 42, "2026-07-28", true, true)); + + Assert.Contains("Agent is ready", File.ReadAllText(Path.Combine(directory.Path, "application.log")), StringComparison.Ordinal); + } + + [Fact] + public void Ready_and_http_request_are_logged_without_sensitive_payloads() + { + using var directory = new TestDirectory(); + LocaleCatalog locale = LocaleCatalog.Load(Path.GetFullPath("config/locales"), "en"); + using (var application = new RollingFileLog(directory.Path, 1024 * 1024, 2)) + { + using var observer = new McpHostLogObserver(application, locale, directory.Path, true, 1024 * 1024, 2); + observer.Ready(new McpReadyInfo("http", "http://127.0.0.1:8080/mcp", 42, "2026-07-28", true, true)); + observer.HttpRequestStarted(new McpHttpRequestStartInfo("connection", "trace", "127.0.0.1", 50123, "POST", "/mcp")); + observer.HttpRequest(new McpHttpRequestInfo(DateTimeOffset.UtcNow, "connection", "trace", "127.0.0.1", 50123, + "127.0.0.1", 8080, "POST", "/mcp", 200, 12.5, "application/json", 100, "test-client", + "2026-07-28", "tools/list", "agent_run")); + } + + string applicationText = File.ReadAllText(Path.Combine(directory.Path, "application.log")); + Assert.Contains("Agent is ready", applicationText, StringComparison.Ordinal); + Assert.Contains("remote=127.0.0.1:50123", applicationText, StringComparison.Ordinal); + string httpText = File.ReadAllText(Path.Combine(directory.Path, "http.jsonl")); + Assert.Contains("\"method\":\"POST\"", httpText, StringComparison.Ordinal); + Assert.Contains("\"remote_ip\":\"127.0.0.1\"", httpText, StringComparison.Ordinal); + Assert.DoesNotContain("Authorization", httpText, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("Cookie", httpText, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("prompt", httpText, StringComparison.OrdinalIgnoreCase); + } +} diff --git a/tests/Agent.Core.Tests/PersistentMcpTaskStoreTests.cs b/tests/Agent.Core.Tests/PersistentMcpTaskStoreTests.cs new file mode 100644 index 0000000..2b43459 --- /dev/null +++ b/tests/Agent.Core.Tests/PersistentMcpTaskStoreTests.cs @@ -0,0 +1,36 @@ +using System.Text.Json; +using Agent.Mcp; +using ModelContextProtocol.Extensions.Tasks; + +namespace Agent.Core.Tests; + +public sealed class PersistentMcpTaskStoreTests +{ + [Fact] + public async Task Persists_terminal_task_and_marks_incomplete_task_failed_after_restart() + { + using var directory = new TestDirectory(); + var settings = new McpTaskStoreSettings(directory.Path, TimeSpan.FromHours(24), 1000); + var first = new PersistentMcpTaskStore(settings); + McpTaskInfo completed = await first.CreateTaskAsync(TestContext.Current.CancellationToken); + await first.SetCompletedAsync(completed.TaskId, JsonSerializer.SerializeToElement(new { ok = true }), TestContext.Current.CancellationToken); + McpTaskInfo working = await first.CreateTaskAsync(TestContext.Current.CancellationToken); + + var recovered = new PersistentMcpTaskStore(settings); + Assert.Equal(McpTaskStatus.Completed, (await recovered.GetTaskAsync(completed.TaskId, TestContext.Current.CancellationToken))!.Status); + McpTaskInfo failed = (await recovered.GetTaskAsync(working.TaskId, TestContext.Current.CancellationToken))!; + Assert.Equal(McpTaskStatus.Failed, failed.Status); + Assert.Equal("server_restarted", failed.StatusMessage); + } + + [Fact] + public async Task Expired_task_is_removed() + { + using var directory = new TestDirectory(); + var store = new PersistentMcpTaskStore(new McpTaskStoreSettings(directory.Path, TimeSpan.FromMilliseconds(1), 1000)); + McpTaskInfo task = await store.CreateTaskAsync(TestContext.Current.CancellationToken); + await Task.Delay(20, TestContext.Current.CancellationToken); + Assert.Null(await store.GetTaskAsync(task.TaskId, TestContext.Current.CancellationToken)); + Assert.Empty(Directory.EnumerateFiles(directory.Path, "*.json")); + } +} diff --git a/tests/Agent.Core.Tests/SharedFakeOllamaServer.cs b/tests/Agent.Core.Tests/SharedFakeOllamaServer.cs new file mode 100644 index 0000000..3264c77 --- /dev/null +++ b/tests/Agent.Core.Tests/SharedFakeOllamaServer.cs @@ -0,0 +1,55 @@ +using System.Net; +using System.Net.Sockets; +using System.Text; + +namespace Agent.Core.Tests; + +internal sealed class SharedFakeOllamaServer : IAsyncDisposable +{ + private readonly TcpListener _listener = new(IPAddress.Loopback, 0); + private readonly CancellationTokenSource _stop = new(); + private readonly Task _loop; + + public SharedFakeOllamaServer() + { + _listener.Start(); + Endpoint = new Uri($"http://127.0.0.1:{((IPEndPoint)_listener.LocalEndpoint).Port}/"); + _loop = RunAsync(); + } + + public Uri Endpoint { get; } + + private async Task RunAsync() + { + try + { + while (!_stop.IsCancellationRequested) + { + using TcpClient client = await _listener.AcceptTcpClientAsync(_stop.Token).ConfigureAwait(false); + NetworkStream stream = client.GetStream(); + using var reader = new StreamReader(stream, Encoding.ASCII, false, 1024, true); + int contentLength = 0; + while (await reader.ReadLineAsync(_stop.Token).ConfigureAwait(false) is { } line && line.Length > 0) + if (line.StartsWith("Content-Length:", StringComparison.OrdinalIgnoreCase)) contentLength = int.Parse(line[15..].Trim(), System.Globalization.CultureInfo.InvariantCulture); + if (contentLength > 0) + { + char[] body = new char[contentLength]; + await reader.ReadBlockAsync(body.AsMemory(), _stop.Token).ConfigureAwait(false); + } + byte[] payload = Encoding.UTF8.GetBytes("""{"message":{"role":"assistant","content":"done"},"prompt_eval_count":3,"eval_count":2}"""); + byte[] header = Encoding.ASCII.GetBytes($"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {payload.Length}\r\nConnection: close\r\n\r\n"); + await stream.WriteAsync(header, _stop.Token).ConfigureAwait(false); + await stream.WriteAsync(payload, _stop.Token).ConfigureAwait(false); + } + } + catch (OperationCanceledException) when (_stop.IsCancellationRequested) { } + } + + public async ValueTask DisposeAsync() + { + await _stop.CancelAsync().ConfigureAwait(false); + _listener.Stop(); + await _loop.ConfigureAwait(false); + _stop.Dispose(); + } +} diff --git a/tests/Agent.Core.Tests/TaskRegistryTests.cs b/tests/Agent.Core.Tests/TaskRegistryTests.cs index 33e16ab..28a2844 100644 --- a/tests/Agent.Core.Tests/TaskRegistryTests.cs +++ b/tests/Agent.Core.Tests/TaskRegistryTests.cs @@ -5,6 +5,19 @@ namespace Agent.Core.Tests; public sealed class TaskRegistryTests { [Fact] + public async Task Explicit_task_id_can_be_cancelled_and_observed_as_terminal() + { + var registry = new TaskRegistry(); + Guid taskId = Guid.NewGuid(); + TaskSnapshot queued = registry.Enqueue("work", null, null, requestedTaskId: taskId); + Assert.Equal(taskId, queued.TaskId); + Assert.True(registry.Cancel(taskId)); + TaskSnapshot terminal = await registry.WaitForTerminalAsync(taskId, TestContext.Current.CancellationToken); + Assert.Equal(AgentTaskState.Cancelled, terminal.State); + Assert.True(registry.GetCancellationToken(taskId).IsCancellationRequested); + } + + [Fact] public void NewTaskGetsUniqueTaskAndSessionIds() { var registry = new TaskRegistry(); |
