summaryrefslogtreecommitdiff
path: root/tests/Agent.Core.Tests/McpStdioTests.cs
diff options
context:
space:
mode:
authortanaka takashi <isazaka@gmail.com>2026-08-09 21:08:20 +0900
committertanaka takashi <isazaka@gmail.com>2026-08-09 21:08:20 +0900
commitb07b41cd435991cd254957af79055080c85b346e (patch)
tree27720d3ce14e4d0b84670a55ac26a2f877ce3a20 /tests/Agent.Core.Tests/McpStdioTests.cs
parent2fefd29e86c3b926d0cf4a362251d3f119688612 (diff)
feat: AI Agent Sandbox MCP Server v1を実装
Specifications、Design、ImplementationPlanの優先順位を整理し、単一Task実行モデル、typed option優先、設定可能なresource上限、local Ollamaを必須とするRelease Gateを文書化。 .NET 10/C# 14の5 production project、3 test project、E2E Clientを構築。typed YAML、en/ja locale、fixed/user prompt、stdioおよびloopback限定Streamable HTTP、agent_run/agent_status、Session、Ollama text/tool/Vision、TaskGraphとtoken accountingを実装。 Windows sandboxとしてfixed AppContainer、canonical/reparse検査、能力別ACL、path+SHA-256 executable approval、child-process restriction、Job Object、task journal、atomic file transaction、crash recoveryを実装。WFPのIPv4/IPv6 default denyとexact allow、dynamic session、temporary loopback exemption、管理者/BFE診断を追加。 Security/Recovery test、MCP/Ollama integration test、stdio/HTTP Operational E2E、Release Bundle生成・checksum・manifest・fresh extraction検査を追加。Requirement TraceabilityとPhase 0〜11の進捗、初回セットアップ可能なREADMEを整備。 Debug/Release/publish/objをrepository rootのbuild/へ集約し、旧project-local bin/objへの依存を除去。artifacts/とbuild/をGit管理外に設定し、clean checkoutでもsolution buildからE2E Clientまで生成可能にした。
Diffstat (limited to 'tests/Agent.Core.Tests/McpStdioTests.cs')
-rw-r--r--tests/Agent.Core.Tests/McpStdioTests.cs157
1 files changed, 157 insertions, 0 deletions
diff --git a/tests/Agent.Core.Tests/McpStdioTests.cs b/tests/Agent.Core.Tests/McpStdioTests.cs
new file mode 100644
index 0000000..66f4851
--- /dev/null
+++ b/tests/Agent.Core.Tests/McpStdioTests.cs
@@ -0,0 +1,157 @@
+using Agent.App.Configuration;
+using ModelContextProtocol;
+using ModelContextProtocol.Client;
+using ModelContextProtocol.Protocol;
+using System.Net;
+using System.Net.Sockets;
+using System.Text;
+
+namespace Agent.Core.Tests;
+
+public sealed class McpStdioTests
+{
+ [Fact(Timeout = 30_000)]
+ public async Task InitializeListAndCallExposeOnlyTwoStrictTools()
+ {
+ using var directory = new TestDirectory();
+ await using var ollama = new FakeOllamaServer();
+ string configPath = CreateBundleConfiguration(directory, ollama.Endpoint);
+ string application = typeof(ConfigurationLoader).Assembly.Location;
+ var transport = new StdioClientTransport(new StdioClientTransportOptions
+ {
+ Name = "agent-mcp-test",
+ Command = "dotnet",
+ Arguments = [application, "serve", "--config", configPath, "--lang", "en"],
+ WorkingDirectory = directory.Path
+ });
+
+ await using McpClient client = await McpClient.CreateAsync(transport, 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)
+ {
+ Assert.False(tool.JsonSchema.GetProperty("additionalProperties").GetBoolean());
+ }
+
+ CallToolResult run = await client.CallToolAsync(
+ "agent_run",
+ new Dictionary<string, object?> { ["request"] = "deterministic test" },
+ cancellationToken: TestContext.Current.CancellationToken);
+ string text = Assert.IsType<TextContentBlock>(Assert.Single(run.Content)).Text;
+ Assert.Contains("status: queued", text, StringComparison.Ordinal);
+ Guid taskId = ReadGuid(text, "task_id");
+ Guid sessionId = ReadGuid(text, "session_id");
+
+ CallToolResult status = await client.CallToolAsync(
+ "agent_status",
+ new Dictionary<string, object?> { ["task_id"] = taskId, ["session_id"] = sessionId },
+ cancellationToken: TestContext.Current.CancellationToken);
+ Assert.Contains("task_id:", Assert.IsType<TextContentBlock>(Assert.Single(status.Content)).Text, StringComparison.Ordinal);
+
+ CallToolResult invalid = await client.CallToolAsync(
+ "agent_run",
+ new Dictionary<string, object?> { ["request"] = "x", ["model"] = "forbidden" },
+ cancellationToken: TestContext.Current.CancellationToken);
+ Assert.True(invalid.IsError);
+
+ CallToolResult invalidImage = await client.CallToolAsync(
+ "agent_run",
+ new Dictionary<string, object?>
+ {
+ ["request"] = "x",
+ ["images"] = new[] { new Dictionary<string, object?> { ["mime_type"] = "image/gif", ["data_base64"] = "AA==" } }
+ },
+ cancellationToken: TestContext.Current.CancellationToken);
+ Assert.True(invalidImage.IsError);
+ }
+
+ private static Guid ReadGuid(string text, string key)
+ {
+ string line = text.Split('\n', StringSplitOptions.TrimEntries).Single(value => value.StartsWith(key + ":", StringComparison.Ordinal));
+ return Guid.Parse(line[(key.Length + 1)..].Trim());
+ }
+
+ private static string CreateBundleConfiguration(TestDirectory directory, Uri endpoint)
+ {
+ 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
+ """);
+ }
+
+ private sealed class FakeOllamaServer : IAsyncDisposable
+ {
+ private readonly TcpListener _listener = new(IPAddress.Loopback, 0);
+ private readonly CancellationTokenSource _stop = new();
+ private readonly Task _loop;
+
+ public FakeOllamaServer()
+ {
+ _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);
+ await HandleAsync(client, _stop.Token);
+ }
+ }
+ catch (OperationCanceledException) when (_stop.IsCancellationRequested) { }
+ }
+
+ private static async Task HandleAsync(TcpClient client, CancellationToken cancellationToken)
+ {
+ NetworkStream stream = client.GetStream();
+ using var reader = new StreamReader(stream, Encoding.ASCII, false, 1024, true);
+ int contentLength = 0;
+ while (await reader.ReadLineAsync(cancellationToken) 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(), cancellationToken);
+ }
+ 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, cancellationToken);
+ await stream.WriteAsync(payload, cancellationToken);
+ }
+
+ public async ValueTask DisposeAsync()
+ {
+ await _stop.CancelAsync();
+ _listener.Stop();
+ await _loop;
+ _stop.Dispose();
+ }
+ }
+}