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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user