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まで生成可能にした。 --- src/Agent.Mcp/Agent.Mcp.csproj | 12 ++++ src/Agent.Mcp/AgentTools.cs | 90 +++++++++++++++++++++++++ src/Agent.Mcp/AssemblyMarker.cs | 3 + src/Agent.Mcp/McpHost.cs | 136 ++++++++++++++++++++++++++++++++++++++ src/Agent.Mcp/McpImageInput.cs | 12 ++++ src/Agent.Mcp/QueuedTaskWorker.cs | 18 +++++ src/Agent.Mcp/VisionLimits.cs | 3 + 7 files changed, 274 insertions(+) create mode 100644 src/Agent.Mcp/Agent.Mcp.csproj create mode 100644 src/Agent.Mcp/AgentTools.cs create mode 100644 src/Agent.Mcp/AssemblyMarker.cs create mode 100644 src/Agent.Mcp/McpHost.cs create mode 100644 src/Agent.Mcp/McpImageInput.cs create mode 100644 src/Agent.Mcp/QueuedTaskWorker.cs create mode 100644 src/Agent.Mcp/VisionLimits.cs (limited to 'src/Agent.Mcp') diff --git a/src/Agent.Mcp/Agent.Mcp.csproj b/src/Agent.Mcp/Agent.Mcp.csproj new file mode 100644 index 0000000..5b09556 --- /dev/null +++ b/src/Agent.Mcp/Agent.Mcp.csproj @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/src/Agent.Mcp/AgentTools.cs b/src/Agent.Mcp/AgentTools.cs new file mode 100644 index 0000000..c44d6ec --- /dev/null +++ b/src/Agent.Mcp/AgentTools.cs @@ -0,0 +1,90 @@ +using System.ComponentModel; +using Agent.Core.Localization; +using Agent.Core.Tasks; +using ModelContextProtocol.Server; +using ModelContextProtocol.Protocol; + +namespace Agent.Mcp; + +[McpServerToolType] +public sealed class AgentTools(TaskRegistry registry, LocaleCatalog locale, VisionLimits visionLimits) +{ + [McpServerTool(Name = "agent_run", ReadOnly = false, Destructive = true, OpenWorld = false, UseStructuredContent = false)] + [Description("Queue a natural-language request for asynchronous execution.")] + public string AgentRun( + RequestContext context, + [Description("Non-empty natural-language work request.")] string request, + [Description("Existing session UUID, or omit to create a session.")] Guid? session_id = null, + [Description("Task-scoped temperature from 0.0 through 2.0.")] double? temperature = null, + [Description("Optional PNG, JPEG, or WebP images.")] IReadOnlyList? images = null) + { + RequireOnly(context, "request", "session_id", "temperature", "images"); + ValidateImages(images); + TaskSnapshot task = registry.Enqueue(request, session_id, temperature, images?.Select(static image => image.DataBase64).ToArray()); + string message = locale.Format("mcp.task_queued", new Dictionary + { + ["task"] = task.TaskId, + ["session"] = task.SessionId + }); + return string.Join('\n', + $"task_id: {task.TaskId}", + $"session_id: {task.SessionId}", + "status: queued", + $"message: {message}"); + } + + private void ValidateImages(IReadOnlyList? images) + { + if (images is null) return; + if (images.Count > visionLimits.MaxCount) throw new ArgumentException($"Image count exceeds configured limit {visionLimits.MaxCount}."); + long total = 0; + foreach (McpImageInput image in images) + { + if (image.MimeType is not ("image/png" or "image/jpeg" or "image/webp")) throw new ArgumentException($"Unsupported image MIME type '{image.MimeType}'."); + byte[] bytes; + try { bytes = Convert.FromBase64String(image.DataBase64); } + catch (FormatException ex) { throw new ArgumentException("Image data_base64 is invalid.", ex); } + if (bytes.LongLength > visionLimits.MaxBytesEach) throw new ArgumentException($"Image exceeds configured per-image limit {visionLimits.MaxBytesEach} bytes."); + total = checked(total + bytes.LongLength); + if (total > visionLimits.MaxTotalBytes) throw new ArgumentException($"Images exceed configured total limit {visionLimits.MaxTotalBytes} bytes."); + } + } + + [McpServerTool(Name = "agent_status", ReadOnly = true, Destructive = false, OpenWorld = false, UseStructuredContent = false)] + [Description("Get the current state and result of a queued agent task.")] + public string AgentStatus( + RequestContext context, + [Description("Task UUID returned by agent_run.")] Guid task_id, + [Description("Optional session UUID used to verify task ownership.")] Guid? session_id = null) + { + RequireOnly(context, "task_id", "session_id"); + if (!registry.TryGet(task_id, session_id, out TaskSnapshot? task) || task is null) + { + return string.Join('\n', + $"task_id: {task_id}", + "status: failed", + $"message: {locale.Format("mcp.task_not_found", new Dictionary { ["task"] = task_id })}"); + } + + string state = task.State.ToString().ToLowerInvariant(); + bool terminal = task.State is AgentTaskState.Completed or AgentTaskState.Failed or AgentTaskState.Cancelled; + return string.Join('\n', + $"task_id: {task.TaskId}", + $"session_id: {task.SessionId}", + $"status: {state}", + $"completed_tasks: {task.CompletedTasks}/{task.TotalTasks}", + $"current_task: {task.CurrentTask ?? string.Empty}", + $"token_usage: input={task.InputTokens} output={task.OutputTokens} total={task.InputTokens + task.OutputTokens}", + $"critical_path_tokens: {task.CriticalPathTokens.ToString(System.Globalization.CultureInfo.InvariantCulture)}", + $"message: {locale.Get("mcp.status." + state)}", + terminal ? $"result: {task.Result}" : "result:"); + } + + private static void RequireOnly(RequestContext context, params string[] allowed) + { + if (context.Params?.Arguments is null) return; + HashSet allowedSet = allowed.ToHashSet(StringComparer.Ordinal); + string? unknown = context.Params.Arguments.Keys.FirstOrDefault(key => !allowedSet.Contains(key)); + if (unknown is not null) throw new ArgumentException($"Unknown tool property '{unknown}'."); + } +} diff --git a/src/Agent.Mcp/AssemblyMarker.cs b/src/Agent.Mcp/AssemblyMarker.cs new file mode 100644 index 0000000..ef6d87f --- /dev/null +++ b/src/Agent.Mcp/AssemblyMarker.cs @@ -0,0 +1,3 @@ +namespace Agent.Mcp; + +public static class AssemblyMarker; diff --git a/src/Agent.Mcp/McpHost.cs b/src/Agent.Mcp/McpHost.cs new file mode 100644 index 0000000..ff546e0 --- /dev/null +++ b/src/Agent.Mcp/McpHost.cs @@ -0,0 +1,136 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; +using System.Text.Json.Nodes; +using System.Reflection; +using Agent.Core.Localization; +using Agent.Core.Tasks; +using Agent.Core.Ollama; +using Agent.Core.Runtime; +using Agent.Core.Tools; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using ModelContextProtocol.Server; + +namespace Agent.Mcp; + +public static class McpHost +{ + public static async Task RunStdioAsync(LocaleCatalog locale, OllamaSettings ollamaSettings, AgentRuntimeSettings runtimeSettings, VisionLimits visionLimits, CancellationToken cancellationToken, InternalToolDispatcher? toolDispatcher = null, IAgentRuntimeObserver? observer = null) + { + HostApplicationBuilder builder = Host.CreateApplicationBuilder(new HostApplicationBuilderSettings + { + Args = [], + ContentRootPath = AppContext.BaseDirectory + }); + builder.Logging.ClearProviders(); + var registry = new TaskRegistry(); + using var httpClient = new HttpClient { Timeout = TimeSpan.FromMinutes(10) }; + var ollama = new OllamaClient(httpClient, ollamaSettings); + var runtime = new AgentRuntime(registry, ollama, runtimeSettings, toolDispatcher, observer); + builder.Services.AddSingleton(locale); + builder.Services.AddSingleton(registry); + builder.Services.AddSingleton(runtime); + builder.Services.AddHostedService(); + + McpServerTool[] tools = CreateTools(registry, locale, visionLimits); + + builder.Services + .AddMcpServer() + .WithStdioServerTransport() + .WithTools(tools); + + using IHost host = builder.Build(); + await host.RunAsync(cancellationToken).ConfigureAwait(false); + } + + public static async Task RunHttpAsync(LocaleCatalog locale, OllamaSettings ollamaSettings, AgentRuntimeSettings runtimeSettings, VisionLimits visionLimits, string hostName, int port, CancellationToken cancellationToken, InternalToolDispatcher? toolDispatcher = null, IAgentRuntimeObserver? observer = null) + { + if (!System.Net.IPAddress.TryParse(hostName, out System.Net.IPAddress? address) || !System.Net.IPAddress.IsLoopback(address)) + throw new ArgumentException("HTTP transport host must be a loopback IP literal.", nameof(hostName)); + WebApplicationBuilder builder = WebApplication.CreateBuilder(new WebApplicationOptions { Args = [], ContentRootPath = AppContext.BaseDirectory }); + builder.Logging.ClearProviders(); + string listenHost = address.AddressFamily == System.Net.Sockets.AddressFamily.InterNetworkV6 ? $"[{address}]" : address.ToString(); + builder.WebHost.UseUrls($"http://{listenHost}:{port}"); + var registry = new TaskRegistry(); + var httpClient = new HttpClient { Timeout = TimeSpan.FromMinutes(10) }; + var runtime = new AgentRuntime(registry, new OllamaClient(httpClient, ollamaSettings), runtimeSettings, toolDispatcher, observer); + builder.Services.AddSingleton(locale); + builder.Services.AddSingleton(registry); + builder.Services.AddSingleton(runtime); + builder.Services.AddHostedService(); + McpServerTool[] tools = CreateTools(registry, locale, visionLimits); + builder.Services.AddMcpServer().WithHttpTransport().WithTools(tools); + await using WebApplication app = builder.Build(); + app.Use(async (context, next) => + { + if (context.Request.Headers.TryGetValue("Origin", out Microsoft.Extensions.Primitives.StringValues origins) && + origins.Any(origin => !IsLoopbackOrigin(origin))) + { + context.Response.StatusCode = StatusCodes.Status403Forbidden; + return; + } + if (context.Request.Path == "/mcp" && HttpMethods.IsGet(context.Request.Method)) + { + context.Response.StatusCode = StatusCodes.Status405MethodNotAllowed; + return; + } + await next(context).ConfigureAwait(false); + }); + app.MapMcp("/mcp"); + try { await app.RunAsync(cancellationToken).ConfigureAwait(false); } + finally { httpClient.Dispose(); } + } + + private static bool IsLoopbackOrigin(string? origin) + { + return Uri.TryCreate(origin, UriKind.Absolute, out Uri? uri) && + (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps) && + (string.Equals(uri.Host, "localhost", StringComparison.OrdinalIgnoreCase) || + (System.Net.IPAddress.TryParse(uri.Host, out System.Net.IPAddress? address) && System.Net.IPAddress.IsLoopback(address))); + } + + private static McpServerTool[] CreateTools(TaskRegistry registry, LocaleCatalog locale, VisionLimits visionLimits) + { + var serializerOptions = new JsonSerializerOptions(JsonSerializerDefaults.Web) + { + PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, + TypeInfoResolver = new DefaultJsonTypeInfoResolver(), + UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow + }; + var target = new AgentTools(registry, locale, visionLimits); + McpServerTool[] tools = typeof(AgentTools) + .GetMethods(BindingFlags.Instance | BindingFlags.Public | BindingFlags.DeclaredOnly) + .Where(static method => method.GetCustomAttribute() is not null) + .Select(method => McpServerTool.Create(method, target, new McpServerToolCreateOptions { SerializerOptions = serializerOptions })) + .ToArray(); + foreach (McpServerTool tool in tools) MakeSchemaStrict(tool); + return tools; + } + + private static void MakeSchemaStrict(McpServerTool tool) + { + JsonNode root = JsonNode.Parse(tool.ProtocolTool.InputSchema.GetRawText()) + ?? throw new InvalidOperationException($"Tool '{tool.ProtocolTool.Name}' has no input schema."); + Visit(root); + tool.ProtocolTool.InputSchema = JsonSerializer.SerializeToElement(root); + + static void Visit(JsonNode? node) + { + if (node is JsonObject obj) + { + if (obj["type"] is JsonValue type && type.TryGetValue(out string? typeName) && typeName == "object") + obj["additionalProperties"] = false; + foreach (KeyValuePair property in obj.ToArray()) Visit(property.Value); + } + else if (node is JsonArray array) + { + foreach (JsonNode? item in array) Visit(item); + } + } + } +} diff --git a/src/Agent.Mcp/McpImageInput.cs b/src/Agent.Mcp/McpImageInput.cs new file mode 100644 index 0000000..9c0bd81 --- /dev/null +++ b/src/Agent.Mcp/McpImageInput.cs @@ -0,0 +1,12 @@ +using System.Text.Json.Serialization; + +namespace Agent.Mcp; + +public sealed class McpImageInput +{ + [JsonPropertyName("mime_type")] + public required string MimeType { get; init; } + + [JsonPropertyName("data_base64")] + public required string DataBase64 { get; init; } +} diff --git a/src/Agent.Mcp/QueuedTaskWorker.cs b/src/Agent.Mcp/QueuedTaskWorker.cs new file mode 100644 index 0000000..23404e3 --- /dev/null +++ b/src/Agent.Mcp/QueuedTaskWorker.cs @@ -0,0 +1,18 @@ +using Agent.Core.Tasks; +using Microsoft.Extensions.Hosting; +using Agent.Core.Runtime; + +namespace Agent.Mcp; + +public sealed class QueuedTaskWorker(TaskRegistry registry, AgentRuntime runtime) : BackgroundService +{ + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + await foreach (Guid taskId in registry.ReadQueuedAsync(stoppingToken).ConfigureAwait(false)) + { + if (!registry.Transition(taskId, AgentTaskState.Planning, "planning")) continue; + registry.Transition(taskId, AgentTaskState.Running, "ollama chat"); + await runtime.ExecuteAsync(taskId, stoppingToken).ConfigureAwait(false); + } + } +} diff --git a/src/Agent.Mcp/VisionLimits.cs b/src/Agent.Mcp/VisionLimits.cs new file mode 100644 index 0000000..ad6dfb3 --- /dev/null +++ b/src/Agent.Mcp/VisionLimits.cs @@ -0,0 +1,3 @@ +namespace Agent.Mcp; + +public sealed record VisionLimits(int MaxCount = 4, long MaxBytesEach = 10 * 1024 * 1024, long MaxTotalBytes = 20 * 1024 * 1024); -- cgit v1.2.3