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 <noreply@anthropic.com>
This commit is contained in:
mika kuns
2026-04-23 23:12:24 +02:00
parent 99c6a71e4c
commit 6cb20a9213
5 changed files with 178 additions and 36 deletions

View File

@@ -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<string> excludedConnectionIds) => NullClientProxy.Instance;
public IClientProxy Client(string connectionId) => NullClientProxy.Instance;
public IClientProxy Clients(IReadOnlyList<string> connectionIds) => NullClientProxy.Instance;
public IClientProxy Group(string groupName) => NullClientProxy.Instance;
public IClientProxy GroupExcept(string groupName, IReadOnlyList<string> excludedConnectionIds) => NullClientProxy.Instance;
public IClientProxy Groups(IReadOnlyList<string> groupNames) => NullClientProxy.Instance;
public IClientProxy User(string userId) => NullClientProxy.Instance;
public IClientProxy Users(IReadOnlyList<string> 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<WorkerHub>
{
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<TaskEntity> 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<InvalidOperationException>(() =>
_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<InvalidOperationException>(() =>
_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);