summaryrefslogtreecommitdiff
path: root/src/Agent.Mcp
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 /src/Agent.Mcp
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 'src/Agent.Mcp')
-rw-r--r--src/Agent.Mcp/Agent.Mcp.csproj12
-rw-r--r--src/Agent.Mcp/AgentTools.cs90
-rw-r--r--src/Agent.Mcp/AssemblyMarker.cs3
-rw-r--r--src/Agent.Mcp/McpHost.cs136
-rw-r--r--src/Agent.Mcp/McpImageInput.cs12
-rw-r--r--src/Agent.Mcp/QueuedTaskWorker.cs18
-rw-r--r--src/Agent.Mcp/VisionLimits.cs3
7 files changed, 274 insertions, 0 deletions
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 @@
+<Project Sdk="Microsoft.NET.Sdk">
+ <ItemGroup>
+ <ProjectReference Include="..\Agent.Core\Agent.Core.csproj" />
+ </ItemGroup>
+ <ItemGroup>
+ <PackageReference Include="ModelContextProtocol" />
+ <PackageReference Include="ModelContextProtocol.AspNetCore" />
+ </ItemGroup>
+ <ItemGroup>
+ <FrameworkReference Include="Microsoft.AspNetCore.App" />
+ </ItemGroup>
+</Project>
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<CallToolRequestParams> 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<McpImageInput>? 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<string, object?>
+ {
+ ["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<McpImageInput>? 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<CallToolRequestParams> 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<string, object?> { ["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<CallToolRequestParams> context, params string[] allowed)
+ {
+ if (context.Params?.Arguments is null) return;
+ HashSet<string> 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<QueuedTaskWorker>();
+
+ 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<QueuedTaskWorker>();
+ 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<McpServerToolAttribute>() 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<string, JsonNode?> 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);