From b07b41cd435991cd254957af79055080c85b346e Mon Sep 17 00:00:00 2001 From: tanaka takashi Date: Sun, 9 Aug 2026 21:08:20 +0900 Subject: feat: AI Agent Sandbox MCP Server v1を実装 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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まで生成可能にした。 --- tests/Agent.Core.Tests/McpStdioTests.cs | 157 ++++++++++++++++++++++++++++++++ 1 file changed, 157 insertions(+) create mode 100644 tests/Agent.Core.Tests/McpStdioTests.cs (limited to 'tests/Agent.Core.Tests/McpStdioTests.cs') 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 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 { ["request"] = "deterministic test" }, + cancellationToken: TestContext.Current.CancellationToken); + string text = Assert.IsType(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 { ["task_id"] = taskId, ["session_id"] = sessionId }, + cancellationToken: TestContext.Current.CancellationToken); + Assert.Contains("task_id:", Assert.IsType(Assert.Single(status.Content)).Text, StringComparison.Ordinal); + + CallToolResult invalid = await client.CallToolAsync( + "agent_run", + new Dictionary { ["request"] = "x", ["model"] = "forbidden" }, + cancellationToken: TestContext.Current.CancellationToken); + Assert.True(invalid.IsError); + + CallToolResult invalidImage = await client.CallToolAsync( + "agent_run", + new Dictionary + { + ["request"] = "x", + ["images"] = new[] { new Dictionary { ["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(); + } + } +} -- cgit v1.2.3