From 6cb20a9213ced69bae1b78a5564debc1a294ae86 Mon Sep 17 00:00:00 2001 From: mika kuns Date: Thu, 23 Apr 2026 23:12:24 +0200 Subject: [PATCH] feat(worker): map MCP HTTP endpoint and broadcast TaskUpdated - Add PlanningMcpContextAccessor (Option A) to read PlanningMcpContext from HttpContext.Items set by PlanningTokenAuthMiddleware - Annotate PlanningMcpService with [McpServerToolType]/[McpServerTool] and remove PlanningMcpContext ctx parameter from all tool methods - Broadcast TaskUpdated(parentTaskId) via HubBroadcaster after every mutation in PlanningMcpService - Refactor PlanningSessionManager to accept IDbContextFactory for singleton-safe use in DI; keep direct-repo ctor for tests - Register PlanningSessionManager (singleton), IPlanningTerminalLauncher, PlanningMcpContextAccessor, PlanningMcpService, and MCP server in Program.cs; wire PlanningTokenAuthMiddleware and MapMcp("/mcp") - Update PlanningMcpServiceTests with fake HttpContext accessor and no-op HubBroadcaster (avoids Moq dependency) Co-Authored-By: Claude Sonnet 4.6 --- .../Planning/PlanningMcpContextAccessor.cs | 14 ++++ .../Planning/PlanningMcpService.cs | 52 ++++++++++---- .../Planning/PlanningSessionManager.cs | 60 ++++++++++++---- src/ClaudeDo.Worker/Program.cs | 19 +++++ .../Planning/PlanningMcpServiceTests.cs | 69 ++++++++++++++++--- 5 files changed, 178 insertions(+), 36 deletions(-) create mode 100644 src/ClaudeDo.Worker/Planning/PlanningMcpContextAccessor.cs diff --git a/src/ClaudeDo.Worker/Planning/PlanningMcpContextAccessor.cs b/src/ClaudeDo.Worker/Planning/PlanningMcpContextAccessor.cs new file mode 100644 index 0000000..98bb220 --- /dev/null +++ b/src/ClaudeDo.Worker/Planning/PlanningMcpContextAccessor.cs @@ -0,0 +1,14 @@ +using Microsoft.AspNetCore.Http; + +namespace ClaudeDo.Worker.Planning; + +public sealed class PlanningMcpContextAccessor +{ + private readonly IHttpContextAccessor _http; + + public PlanningMcpContextAccessor(IHttpContextAccessor http) => _http = http; + + public PlanningMcpContext Current => + (_http.HttpContext?.Items["PlanningContext"] as PlanningMcpContext) + ?? throw new InvalidOperationException("No planning context on request."); +} diff --git a/src/ClaudeDo.Worker/Planning/PlanningMcpService.cs b/src/ClaudeDo.Worker/Planning/PlanningMcpService.cs index f22feb1..302fe08 100644 --- a/src/ClaudeDo.Worker/Planning/PlanningMcpService.cs +++ b/src/ClaudeDo.Worker/Planning/PlanningMcpService.cs @@ -1,5 +1,8 @@ +using System.ComponentModel; using ClaudeDo.Data.Models; using ClaudeDo.Data.Repositories; +using ClaudeDo.Worker.Hub; +using ModelContextProtocol.Server; using TaskStatus = ClaudeDo.Data.Models.TaskStatus; namespace ClaudeDo.Worker.Planning; @@ -7,28 +10,45 @@ namespace ClaudeDo.Worker.Planning; public sealed record ChildTaskDto(string TaskId, string Title, string? Description, string Status, IReadOnlyList Tags); public sealed record CreatedChildDto(string TaskId, string Status); +[McpServerToolType] public sealed class PlanningMcpService { private readonly TaskRepository _tasks; + private readonly PlanningMcpContextAccessor _contextAccessor; + private readonly HubBroadcaster _broadcaster; - public PlanningMcpService(TaskRepository tasks) => _tasks = tasks; + public PlanningMcpService( + TaskRepository tasks, + PlanningMcpContextAccessor contextAccessor, + HubBroadcaster broadcaster) + { + _tasks = tasks; + _contextAccessor = contextAccessor; + _broadcaster = broadcaster; + } + private Task BroadcastTaskUpdatedAsync(string taskId, CancellationToken ct) + => _broadcaster.TaskUpdated(taskId); + + [McpServerTool, Description("Create a new draft child task under the current planning session's parent task.")] public async Task CreateChildTask( - PlanningMcpContext ctx, string title, string? description, IReadOnlyList? tags, string? commitType, CancellationToken cancellationToken) { + var ctx = _contextAccessor.Current; var child = await _tasks.CreateChildAsync(ctx.ParentTaskId, title, description, tags, commitType, cancellationToken); + await BroadcastTaskUpdatedAsync(ctx.ParentTaskId, cancellationToken); return new CreatedChildDto(child.Id, "Draft"); } + [McpServerTool, Description("List all child tasks under the current planning session's parent task.")] public async Task> ListChildTasks( - PlanningMcpContext ctx, CancellationToken cancellationToken) { + var ctx = _contextAccessor.Current; var children = await _tasks.GetChildrenAsync(ctx.ParentTaskId, cancellationToken); var list = new List(children.Count); foreach (var c in children) @@ -39,8 +59,8 @@ public sealed class PlanningMcpService return list; } + [McpServerTool, Description("Update an existing draft child task. Only Draft tasks may be modified.")] public async Task UpdateChildTask( - PlanningMcpContext ctx, string taskId, string? title, string? description, @@ -48,6 +68,7 @@ public sealed class PlanningMcpService string? commitType, CancellationToken cancellationToken) { + var ctx = _contextAccessor.Current; var child = await _tasks.GetByIdAsync(taskId, cancellationToken) ?? throw new InvalidOperationException($"Task {taskId} not found."); if (child.ParentTaskId != ctx.ParentTaskId) @@ -60,19 +81,18 @@ public sealed class PlanningMcpService if (commitType is not null) child.CommitType = commitType; await _tasks.UpdateAsync(child, cancellationToken); - // Tag handling omitted for v1 simplicity — tags set at create time. - // If Claude asks to update tags, it can delete and re-create. - var reload = (await _tasks.GetByIdAsync(taskId, cancellationToken))!; var tagList = await _tasks.GetTagsAsync(reload.Id, cancellationToken); + await BroadcastTaskUpdatedAsync(ctx.ParentTaskId, cancellationToken); return new ChildTaskDto(reload.Id, reload.Title, reload.Description, reload.Status.ToString(), tagList.Select(t => t.Name).ToList()); } + [McpServerTool, Description("Delete a draft child task. Only Draft tasks may be deleted.")] public async Task DeleteChildTask( - PlanningMcpContext ctx, string taskId, CancellationToken cancellationToken) { + var ctx = _contextAccessor.Current; var child = await _tasks.GetByIdAsync(taskId, cancellationToken) ?? throw new InvalidOperationException($"Task {taskId} not found."); if (child.ParentTaskId != ctx.ParentTaskId) @@ -81,20 +101,28 @@ public sealed class PlanningMcpService throw new InvalidOperationException("Cannot delete a finalized task."); await _tasks.DeleteAsync(taskId, cancellationToken); + await BroadcastTaskUpdatedAsync(ctx.ParentTaskId, cancellationToken); } + [McpServerTool, Description("Update the title and/or description of the parent planning task itself.")] public async Task UpdatePlanningTask( - PlanningMcpContext ctx, string? title, string? description, CancellationToken cancellationToken) { + var ctx = _contextAccessor.Current; await _tasks.UpdatePlanningTaskAsync(ctx.ParentTaskId, title, description, cancellationToken); + await BroadcastTaskUpdatedAsync(ctx.ParentTaskId, cancellationToken); } - public Task Finalize( - PlanningMcpContext ctx, + [McpServerTool, Description("Finalize the planning session, promoting all draft child tasks to queued or manual status.")] + public async Task Finalize( bool queueAgentTasks, CancellationToken cancellationToken) - => _tasks.FinalizePlanningAsync(ctx.ParentTaskId, queueAgentTasks, cancellationToken); + { + var ctx = _contextAccessor.Current; + var count = await _tasks.FinalizePlanningAsync(ctx.ParentTaskId, queueAgentTasks, cancellationToken); + await BroadcastTaskUpdatedAsync(ctx.ParentTaskId, cancellationToken); + return count; + } } diff --git a/src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs b/src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs index e126e15..1f0f88d 100644 --- a/src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs +++ b/src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs @@ -1,8 +1,10 @@ using System.Security.Cryptography; using System.Text; using System.Text.Json; +using ClaudeDo.Data; using ClaudeDo.Data.Models; using ClaudeDo.Data.Repositories; +using Microsoft.EntityFrameworkCore; using TaskStatus = ClaudeDo.Data.Models.TaskStatus; namespace ClaudeDo.Worker.Planning; @@ -11,20 +13,40 @@ public sealed class PlanningSessionManager { private const string McpServerUrl = "http://127.0.0.1:47821/mcp"; - private readonly TaskRepository _tasks; - private readonly ListRepository _lists; + private readonly IDbContextFactory? _factory; + private readonly TaskRepository? _tasksOverride; + private readonly ListRepository? _listsOverride; private readonly string _rootDirectory; + // DI constructor — uses factory so this singleton can create scoped repos per call. + public PlanningSessionManager(IDbContextFactory factory, string rootDirectory) + { + _factory = factory; + _rootDirectory = rootDirectory; + } + + // Test constructor — accepts repos directly (single shared context, test-scoped). public PlanningSessionManager(TaskRepository tasks, ListRepository lists, string rootDirectory) { - _tasks = tasks; - _lists = lists; + _tasksOverride = tasks; + _listsOverride = lists; _rootDirectory = rootDirectory; } + private (TaskRepository tasks, ListRepository lists, ClaudeDoDbContext? ctx) CreateRepos() + { + if (_tasksOverride is not null) + return (_tasksOverride, _listsOverride!, null); + var ctx = _factory!.CreateDbContext(); + return (new TaskRepository(ctx), new ListRepository(ctx), ctx); + } + public async Task StartAsync(string taskId, CancellationToken ct) { - var task = await _tasks.GetByIdAsync(taskId, ct) + var (tasks, lists, ctx) = CreateRepos(); + await using var _ = ctx; + + var task = await tasks.GetByIdAsync(taskId, ct) ?? throw new InvalidOperationException($"Task {taskId} not found."); if (task.ParentTaskId is not null) throw new InvalidOperationException("Cannot start a planning session on a child task."); @@ -32,7 +54,7 @@ public sealed class PlanningSessionManager throw new InvalidOperationException($"Task is in status {task.Status}; only Manual can start planning."); var token = GenerateToken(); - _ = await _tasks.SetPlanningStartedAsync(taskId, token, ct) + var started = await tasks.SetPlanningStartedAsync(taskId, token, ct) ?? throw new InvalidOperationException("Failed to transition task to Planning."); var sessionDir = Path.Combine(_rootDirectory, taskId); @@ -48,23 +70,32 @@ public sealed class PlanningSessionManager await File.WriteAllTextAsync(files.SystemPromptPath, BuildSystemPrompt(), ct); await File.WriteAllTextAsync(files.InitialPromptPath, BuildInitialPrompt(task), ct); - var list = await _lists.GetByIdAsync(task.ListId, ct) + var list = await lists.GetByIdAsync(task.ListId, ct) ?? throw new InvalidOperationException($"List {task.ListId} not found."); return new PlanningSessionStartContext(taskId, list.WorkingDir, files); } - public Task FinalizeAsync(string taskId, bool queueAgentTasks, CancellationToken ct) - => _tasks.FinalizePlanningAsync(taskId, queueAgentTasks, ct); + public async Task FinalizeAsync(string taskId, bool queueAgentTasks, CancellationToken ct) + { + var (tasks, _, ctx) = CreateRepos(); + await using var __ = ctx; + return await tasks.FinalizePlanningAsync(taskId, queueAgentTasks, ct); + } public async Task GetPendingDraftCountAsync(string taskId, CancellationToken ct) { - var children = await _tasks.GetChildrenAsync(taskId, ct); + var (tasks, _, ctx) = CreateRepos(); + await using var __ = ctx; + var children = await tasks.GetChildrenAsync(taskId, ct); return children.Count(c => c.Status == TaskStatus.Draft); } public async Task DiscardAsync(string taskId, CancellationToken ct) { - var ok = await _tasks.DiscardPlanningAsync(taskId, ct); + var (tasks, _, ctx) = CreateRepos(); + await using var __ = ctx; + + var ok = await tasks.DiscardPlanningAsync(taskId, ct); var sessionDir = Path.Combine(_rootDirectory, taskId); if (Directory.Exists(sessionDir)) { @@ -77,7 +108,10 @@ public sealed class PlanningSessionManager public async Task ResumeAsync(string taskId, CancellationToken ct) { - var task = await _tasks.GetByIdAsync(taskId, ct) + var (tasks, lists, ctx) = CreateRepos(); + await using var _ = ctx; + + var task = await tasks.GetByIdAsync(taskId, ct) ?? throw new InvalidOperationException($"Task {taskId} not found."); if (task.Status != TaskStatus.Planning) throw new InvalidOperationException($"Task is in status {task.Status}; resume requires Planning."); @@ -89,7 +123,7 @@ public sealed class PlanningSessionManager if (!File.Exists(mcpConfigPath)) throw new InvalidOperationException($"Session directory missing: {sessionDir}"); - var list = await _lists.GetByIdAsync(task.ListId, ct) + var list = await lists.GetByIdAsync(task.ListId, ct) ?? throw new InvalidOperationException($"List {task.ListId} not found."); return new PlanningSessionResumeContext(taskId, list.WorkingDir, task.PlanningSessionId, mcpConfigPath); } diff --git a/src/ClaudeDo.Worker/Program.cs b/src/ClaudeDo.Worker/Program.cs index 5f4977a..5d0f199 100644 --- a/src/ClaudeDo.Worker/Program.cs +++ b/src/ClaudeDo.Worker/Program.cs @@ -3,6 +3,7 @@ using ClaudeDo.Data.Git; using ClaudeDo.Data.Repositories; using ClaudeDo.Worker.Config; using ClaudeDo.Worker.Hub; +using ClaudeDo.Worker.Planning; using ClaudeDo.Worker.Runner; using ClaudeDo.Worker.Services; using Microsoft.EntityFrameworkCore; @@ -51,6 +52,22 @@ builder.Services.AddSingleton(sp => new DefaultAgentSeeder( builder.Services.AddSingleton(); builder.Services.AddHostedService(sp => sp.GetRequiredService()); +// Planning session services. +var planningSessionsDir = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + ".todo-app", "planning-sessions"); +builder.Services.AddSingleton(sp => + new PlanningSessionManager( + sp.GetRequiredService>(), + planningSessionsDir)); +builder.Services.AddSingleton(); +builder.Services.AddHttpContextAccessor(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddMcpServer() + .WithHttpTransport() + .WithTools(); + // Loopback-only bind. Firewall is irrelevant for 127.0.0.1. builder.WebHost.UseUrls($"http://127.0.0.1:{cfg.SignalRPort}"); @@ -75,7 +92,9 @@ catch (Exception ex) app.Logger.LogWarning(ex, "Default agent seeding failed"); } +app.UseMiddleware(); app.MapHub("/hub"); +app.MapMcp("/mcp"); app.Logger.LogInformation("ClaudeDo.Worker listening on http://127.0.0.1:{Port} (db: {Db})", cfg.SignalRPort, cfg.DbPath); diff --git a/tests/ClaudeDo.Worker.Tests/Planning/PlanningMcpServiceTests.cs b/tests/ClaudeDo.Worker.Tests/Planning/PlanningMcpServiceTests.cs index 6a9fe5e..837912a 100644 --- a/tests/ClaudeDo.Worker.Tests/Planning/PlanningMcpServiceTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Planning/PlanningMcpServiceTests.cs @@ -1,30 +1,72 @@ using ClaudeDo.Data; using ClaudeDo.Data.Models; using ClaudeDo.Data.Repositories; +using ClaudeDo.Worker.Hub; using ClaudeDo.Worker.Planning; using ClaudeDo.Worker.Tests.Infrastructure; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.SignalR; using TaskStatus = ClaudeDo.Data.Models.TaskStatus; namespace ClaudeDo.Worker.Tests.Planning; +// Minimal fakes — avoids Moq dependency. +file sealed class FakeHttpContextAccessor : IHttpContextAccessor +{ + public HttpContext? HttpContext { get; set; } +} + +file sealed class NullHubClients : IHubClients +{ + public IClientProxy All => NullClientProxy.Instance; + public IClientProxy AllExcept(IReadOnlyList excludedConnectionIds) => NullClientProxy.Instance; + public IClientProxy Client(string connectionId) => NullClientProxy.Instance; + public IClientProxy Clients(IReadOnlyList connectionIds) => NullClientProxy.Instance; + public IClientProxy Group(string groupName) => NullClientProxy.Instance; + public IClientProxy GroupExcept(string groupName, IReadOnlyList excludedConnectionIds) => NullClientProxy.Instance; + public IClientProxy Groups(IReadOnlyList groupNames) => NullClientProxy.Instance; + public IClientProxy User(string userId) => NullClientProxy.Instance; + public IClientProxy Users(IReadOnlyList userIds) => NullClientProxy.Instance; +} + +file sealed class NullClientProxy : IClientProxy +{ + public static readonly NullClientProxy Instance = new(); + public Task SendCoreAsync(string method, object?[] args, CancellationToken cancellationToken = default) + => Task.CompletedTask; +} + +file sealed class FakeHubContext : IHubContext +{ + public IHubClients Clients { get; } = new NullHubClients(); + public IGroupManager Groups => throw new NotImplementedException(); +} + public sealed class PlanningMcpServiceTests : IDisposable { private readonly DbFixture _db = new(); private readonly ClaudeDoDbContext _ctx; private readonly TaskRepository _tasks; private readonly ListRepository _lists; - private readonly PlanningMcpService _sut; public PlanningMcpServiceTests() { _ctx = _db.CreateContext(); _tasks = new TaskRepository(_ctx); _lists = new ListRepository(_ctx); - _sut = new PlanningMcpService(_tasks); } public void Dispose() { _ctx.Dispose(); _db.Dispose(); } + private PlanningMcpService BuildSut(string parentTaskId) + { + var httpContext = new DefaultHttpContext(); + httpContext.Items["PlanningContext"] = new PlanningMcpContext { ParentTaskId = parentTaskId }; + var accessor = new PlanningMcpContextAccessor(new FakeHttpContextAccessor { HttpContext = httpContext }); + var broadcaster = new HubBroadcaster(new FakeHubContext()); + return new PlanningMcpService(_tasks, accessor, broadcaster); + } + private async Task SeedPlanningParentAsync() { var listId = Guid.NewGuid().ToString(); @@ -43,14 +85,13 @@ public sealed class PlanningMcpServiceTests : IDisposable return (await _tasks.GetByIdAsync(parent.Id))!; } - private static PlanningMcpContext Ctx(string parentId) => new() { ParentTaskId = parentId }; - [Fact] public async Task CreateChildTask_CreatesDraft() { var parent = await SeedPlanningParentAsync(); + var sut = BuildSut(parent.Id); - var result = await _sut.CreateChildTask(Ctx(parent.Id), "My child", "desc", null, null, CancellationToken.None); + var result = await sut.CreateChildTask("My child", "desc", null, null, CancellationToken.None); Assert.Equal("Draft", result.Status); var child = await _tasks.GetByIdAsync(result.TaskId); @@ -67,7 +108,8 @@ public sealed class PlanningMcpServiceTests : IDisposable await _tasks.CreateChildAsync(parent.Id, "mine", null, null, null); await _tasks.CreateChildAsync(other.Id, "theirs", null, null, null); - var list = await _sut.ListChildTasks(Ctx(parent.Id), CancellationToken.None); + var sut = BuildSut(parent.Id); + var list = await sut.ListChildTasks(CancellationToken.None); Assert.Single(list); Assert.Equal("mine", list[0].Title); } @@ -79,8 +121,9 @@ public sealed class PlanningMcpServiceTests : IDisposable var other = await SeedPlanningParentAsync(); var otherChild = await _tasks.CreateChildAsync(other.Id, "x", null, null, null); + var sut = BuildSut(parent.Id); await Assert.ThrowsAsync(() => - _sut.UpdateChildTask(Ctx(parent.Id), otherChild.Id, "new", null, null, null, CancellationToken.None)); + sut.UpdateChildTask(otherChild.Id, "new", null, null, null, CancellationToken.None)); } [Fact] @@ -90,8 +133,9 @@ public sealed class PlanningMcpServiceTests : IDisposable var c = await _tasks.CreateChildAsync(parent.Id, "c", null, null, null); await _tasks.FinalizePlanningAsync(parent.Id, queueAgentTasks: false); + var sut = BuildSut(parent.Id); await Assert.ThrowsAsync(() => - _sut.UpdateChildTask(Ctx(parent.Id), c.Id, "new", null, null, null, CancellationToken.None)); + sut.UpdateChildTask(c.Id, "new", null, null, null, CancellationToken.None)); } [Fact] @@ -100,7 +144,8 @@ public sealed class PlanningMcpServiceTests : IDisposable var parent = await SeedPlanningParentAsync(); var c = await _tasks.CreateChildAsync(parent.Id, "c", null, null, null); - await _sut.DeleteChildTask(Ctx(parent.Id), c.Id, CancellationToken.None); + var sut = BuildSut(parent.Id); + await sut.DeleteChildTask(c.Id, CancellationToken.None); Assert.Null(await _tasks.GetByIdAsync(c.Id)); } @@ -110,7 +155,8 @@ public sealed class PlanningMcpServiceTests : IDisposable { var parent = await SeedPlanningParentAsync(); - await _sut.UpdatePlanningTask(Ctx(parent.Id), "new title", "new desc", CancellationToken.None); + var sut = BuildSut(parent.Id); + await sut.UpdatePlanningTask("new title", "new desc", CancellationToken.None); var loaded = await _tasks.GetByIdAsync(parent.Id); Assert.Equal("new title", loaded!.Title); @@ -124,7 +170,8 @@ public sealed class PlanningMcpServiceTests : IDisposable await _tasks.CreateChildAsync(parent.Id, "c1", null, null, null); await _tasks.CreateChildAsync(parent.Id, "c2", null, null, null); - var count = await _sut.Finalize(Ctx(parent.Id), true, CancellationToken.None); + var sut = BuildSut(parent.Id); + var count = await sut.Finalize(true, CancellationToken.None); Assert.Equal(2, count); var loaded = await _tasks.GetByIdAsync(parent.Id);