# External MCP — CRUD Extensions Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Extend the always-on `ExternalMcpService` with full task CRUD plus tag management so a normal Claude CLI session can fully manage scope-creep tasks via MCP. **Architecture:** Pure extension of the existing service. New repository helper for tag replacement; five new/extended `[McpServerTool]` methods. No DI changes (`TagRepository` is already registered for `TaskRepository`/`ListRepository`). Uses the same `X-ClaudeDo-Key` middleware already in place. **Tech Stack:** .NET 8, EF Core (SQLite), `ModelContextProtocol.Server` (MCP SDK), xUnit. --- ## Pre-flight The test assembly `tests/ClaudeDo.Worker.Tests` currently fails to compile on `main` because of pre-existing in-progress work on `PlanningChainCoordinator` (stale `TaskRunner` / `WorkerHub` constructor calls in `QueueServiceTests.cs`, `QueueServiceSlotGuardTests.cs`, `PlanningHubTests.cs`). This is unrelated to this work and must NOT be fixed here. Consequence: `dotnet test` cannot execute until that refactor lands. Each task's "Run test, verify it fails" step uses `dotnet build` of the **test csproj** to confirm only the new test's compile expectations, and `dotnet build` of the **production csproj** to confirm production code is correct. When the refactor lands, the engineer or user re-runs `dotnet test --filter "FullyQualifiedName~ExternalMcpServiceTests"` to validate the new tests for real. Build commands used throughout (per the project memory note "use csproj, not .slnx, on .NET 8"): ```bash dotnet build src/ClaudeDo.Data/ClaudeDo.Data.csproj dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj dotnet build tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj ``` --- ## File Map | File | Change | |---|---| | `src/ClaudeDo.Data/Repositories/TaskRepository.cs` | Add `SetTagsAsync` (replace tag set, auto-create rows) | | `src/ClaudeDo.Worker/External/ExternalMcpService.cs` | Inject `TagRepository`; extend `AddTask` with `tags`; add `UpdateTask`, `DeleteTask`, `SetTaskTags`, `ListTags` | | `tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs` | New test file with fakes mirroring `Planning/PlanningMcpServiceTests.cs` | `TagRepository.GetAllAsync` already exists — no change needed there. --- ### Task 1: `TaskRepository.SetTagsAsync` **Files:** - Modify: `src/ClaudeDo.Data/Repositories/TaskRepository.cs` (add new method inside the `#region Tags` block, after `RemoveTagAsync`) - Test: `tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryTests.cs` - [ ] **Step 1: Write the failing tests** Append to `tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryTests.cs` (use the existing `MakeTask`/list-seed helpers from that file — match the pattern used in adjacent tests): ```csharp [Fact] public async Task SetTagsAsync_AttachesNewTagsAndCreatesMissingRows() { var listId = await CreateListAsync("L"); var task = MakeTask(listId, "t"); await _tasks.AddAsync(task); await _tasks.SetTagsAsync(task.Id, new[] { "agent", "novel-tag" }); var tags = await _tasks.GetTagsAsync(task.Id); Assert.Contains(tags, t => t.Name == "agent"); Assert.Contains(tags, t => t.Name == "novel-tag"); Assert.Equal(2, tags.Count); } [Fact] public async Task SetTagsAsync_ReplacesExistingTagSet() { var listId = await CreateListAsync("L"); var task = MakeTask(listId, "t"); await _tasks.AddAsync(task); await _tasks.SetTagsAsync(task.Id, new[] { "agent" }); await _tasks.SetTagsAsync(task.Id, new[] { "manual" }); var tags = await _tasks.GetTagsAsync(task.Id); Assert.Single(tags); Assert.Equal("manual", tags[0].Name); } [Fact] public async Task SetTagsAsync_DeduplicatesCaseInsensitively() { var listId = await CreateListAsync("L"); var task = MakeTask(listId, "t"); await _tasks.AddAsync(task); await _tasks.SetTagsAsync(task.Id, new[] { "agent", "AGENT", "Agent" }); var tags = await _tasks.GetTagsAsync(task.Id); Assert.Single(tags); } [Fact] public async Task SetTagsAsync_EmptyListClearsAllTags() { var listId = await CreateListAsync("L"); var task = MakeTask(listId, "t"); await _tasks.AddAsync(task); await _tasks.SetTagsAsync(task.Id, new[] { "agent" }); await _tasks.SetTagsAsync(task.Id, Array.Empty()); Assert.Empty(await _tasks.GetTagsAsync(task.Id)); } ``` - [ ] **Step 2: Run test to verify it fails** ```bash dotnet build tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj ``` Expected: compile error `CS1061: 'TaskRepository' does not contain a definition for 'SetTagsAsync'`. (Existing unrelated `CS7036` errors from `PlanningChainCoordinator` work also appear — ignore.) - [ ] **Step 3: Write minimal implementation** In `src/ClaudeDo.Data/Repositories/TaskRepository.cs`, inside `#region Tags`, after `RemoveTagAsync`: ```csharp public async Task SetTagsAsync(string taskId, IReadOnlyList tagNames, CancellationToken ct = default) { var task = await _context.Tasks.Include(t => t.Tags).FirstOrDefaultAsync(t => t.Id == taskId, ct); if (task is null) return; task.Tags.Clear(); foreach (var name in tagNames.Where(n => !string.IsNullOrWhiteSpace(n)).Distinct(StringComparer.OrdinalIgnoreCase)) { var tag = await _context.Tags.FirstOrDefaultAsync(t => t.Name == name, ct); if (tag is null) { tag = new TagEntity { Name = name }; _context.Tags.Add(tag); } task.Tags.Add(tag); } await _context.SaveChangesAsync(ct); } ``` - [ ] **Step 4: Run test to verify it compiles + production build still passes** ```bash dotnet build src/ClaudeDo.Data/ClaudeDo.Data.csproj ``` Expected: `Build succeeded. 0 Error(s)`. ```bash dotnet build tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj 2>&1 | grep "TaskRepositoryTests" || echo "no errors in TaskRepositoryTests" ``` Expected: no errors specific to `TaskRepositoryTests` (assembly may still fail due to unrelated `PlanningChainCoordinator` issues). - [ ] **Step 5: Commit** ```bash git add src/ClaudeDo.Data/Repositories/TaskRepository.cs tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryTests.cs git commit -m "feat(data): add TaskRepository.SetTagsAsync for full tag-set replacement" ``` --- ### Task 2: New test file scaffolding for `ExternalMcpService` **Files:** - Create: `tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs` This task creates the shared test fakes and one trivial passing test. Subsequent tasks reuse the same fakes. - [ ] **Step 1: Inspect existing patterns** Read `tests/ClaudeDo.Worker.Tests/Planning/PlanningMcpServiceTests.cs` for the `FakeHubContext`/`RecordingClientProxy` pattern and `tests/ClaudeDo.Worker.Tests/Services/QueueServiceTests.cs` for how to construct a real `QueueService` for tests (the same approach is used here — `ExternalMcpService` depends on it for `WakeQueue`/`RunNow`/`CancelTask`). - [ ] **Step 2: Write the test scaffolding** Create `tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs`: ```csharp using ClaudeDo.Data; using ClaudeDo.Data.Models; using ClaudeDo.Data.Repositories; using ClaudeDo.Worker.External; using ClaudeDo.Worker.Hub; using ClaudeDo.Worker.Services; using ClaudeDo.Worker.Tests.Infrastructure; using Microsoft.AspNetCore.SignalR; using TaskStatus = ClaudeDo.Data.Models.TaskStatus; namespace ClaudeDo.Worker.Tests.External; file sealed class RecordingHubClients : IHubClients { public RecordingClientProxy Proxy { get; } = new(); public IClientProxy All => Proxy; public IClientProxy AllExcept(IReadOnlyList excludedConnectionIds) => Proxy; public IClientProxy Client(string connectionId) => Proxy; public IClientProxy Clients(IReadOnlyList connectionIds) => Proxy; public IClientProxy Group(string groupName) => Proxy; public IClientProxy GroupExcept(string groupName, IReadOnlyList excludedConnectionIds) => Proxy; public IClientProxy Groups(IReadOnlyList groupNames) => Proxy; public IClientProxy User(string userId) => Proxy; public IClientProxy Users(IReadOnlyList userIds) => Proxy; } file sealed class RecordingClientProxy : IClientProxy { public List<(string Method, object?[] Args)> Calls { get; } = new(); public Task SendCoreAsync(string method, object?[] args, CancellationToken cancellationToken = default) { Calls.Add((method, args)); return Task.CompletedTask; } } file sealed class FakeHubContext : IHubContext { public RecordingHubClients RecordingClients { get; } = new(); public IHubClients Clients => RecordingClients; public IGroupManager Groups => throw new NotImplementedException(); } public sealed class ExternalMcpServiceTests : IDisposable { private readonly DbFixture _db = new(); private readonly ClaudeDoDbContext _ctx; private readonly TaskRepository _tasks; private readonly ListRepository _lists; private readonly TagRepository _tags; private readonly FakeHubContext _hub; private readonly HubBroadcaster _broadcaster; public ExternalMcpServiceTests() { _ctx = _db.CreateContext(); _tasks = new TaskRepository(_ctx); _lists = new ListRepository(_ctx); _tags = new TagRepository(_ctx); _hub = new FakeHubContext(); _broadcaster = new HubBroadcaster(_hub); } public void Dispose() { _ctx.Dispose(); _db.Dispose(); } private async Task SeedListAsync(string name = "L") { var id = Guid.NewGuid().ToString(); await _lists.AddAsync(new ListEntity { Id = id, Name = name, CreatedAt = DateTime.UtcNow }); return id; } private async Task SeedTaskAsync(string listId, string title = "t", TaskStatus status = TaskStatus.Manual) { var task = new TaskEntity { Id = Guid.NewGuid().ToString(), ListId = listId, Title = title, Status = status, CreatedAt = DateTime.UtcNow, CommitType = "chore", }; await _tasks.AddAsync(task); return task; } // QueueService is needed by ExternalMcpService's constructor. For tests that // only exercise UpdateTask / DeleteTask / SetTaskTags / ListTags / ListTags, // we never call its WakeQueue/RunNow/CancelTask paths, so a real QueueService // built with the same approach used in QueueServiceTests is sufficient. private ExternalMcpService BuildSut(QueueService queue) => new(_tasks, _lists, queue, _broadcaster, _tags); [Fact] public async Task SeededListAndTask_AreRetrievable() { var listId = await SeedListAsync(); var task = await SeedTaskAsync(listId); Assert.NotNull(await _tasks.GetByIdAsync(task.Id)); } } ``` The trivial `SeededListAndTask_AreRetrievable` test exists to confirm the scaffolding compiles and the fakes work, without depending on `ExternalMcpService` itself yet. Note: `BuildSut` uses a 5-argument constructor signature that does not exist yet — this matches the future signature added in Task 3. The compiler will accept this method only after Task 3. - [ ] **Step 3: Verify the file references resolve** Build the test csproj and check for errors specific to `ExternalMcpServiceTests`: ```bash dotnet build tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj 2>&1 | grep "ExternalMcpServiceTests" ``` Expected output: only one error referring to the 5-arg `ExternalMcpService` constructor (resolved in Task 3). No missing-namespace or syntax errors. - [ ] **Step 4: Commit** ```bash git add tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs git commit -m "test(external): scaffold ExternalMcpServiceTests" ``` --- ### Task 3: Inject `TagRepository` into `ExternalMcpService` + add `ListTags` **Files:** - Modify: `src/ClaudeDo.Worker/External/ExternalMcpService.cs` - Modify: `tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs` Smallest possible change to unblock everything else: take the new dependency and ship the simplest tool first. - [ ] **Step 1: Write the failing test** Add to `tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs`. The `BuildSut` helper is defined in Task 2; tests construct `QueueService` the same way `QueueServiceTests.cs` does (look there for the exact constructor argument list and adopt it verbatim): ```csharp [Fact] public async Task ListTags_ReturnsSeededAndCustomTags() { var listId = await SeedListAsync(); var task = await SeedTaskAsync(listId); await _tasks.SetTagsAsync(task.Id, new[] { "agent", "custom-tag" }); using var queue = QueueServiceFactory.Create(_ctx, _broadcaster); // see helper note below var sut = BuildSut(queue); var tags = await sut.ListTags(CancellationToken.None); Assert.Contains(tags, t => t.Name == "agent"); Assert.Contains(tags, t => t.Name == "custom-tag"); } ``` If a `QueueServiceFactory` helper does not already exist in the test project, inline the construction by mirroring the setup found in `tests/ClaudeDo.Worker.Tests/Services/QueueServiceTests.cs` (it builds `QueueService` directly with `IDbContextFactory`, `HubBroadcaster`, fake claude process, etc.). Do NOT call `StartAsync`; just construct and dispose. - [ ] **Step 2: Run test to verify it fails** ```bash dotnet build tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj 2>&1 | grep -E "ExternalMcpService|ExternalMcpServiceTests" ``` Expected: errors about the 5-arg constructor and `ListTags` not existing. - [ ] **Step 3: Implement** In `src/ClaudeDo.Worker/External/ExternalMcpService.cs`: 1. Add `TagRepository` field and constructor parameter: ```csharp private readonly TaskRepository _tasks; private readonly ListRepository _lists; private readonly QueueService _queue; private readonly HubBroadcaster _broadcaster; private readonly TagRepository _tags; public ExternalMcpService( TaskRepository tasks, ListRepository lists, QueueService queue, HubBroadcaster broadcaster, TagRepository tags) { _tasks = tasks; _lists = lists; _queue = queue; _broadcaster = broadcaster; _tags = tags; } ``` 2. Add a tag DTO above the class (next to `TaskListDto`): ```csharp public sealed record TagDto(long Id, string Name); ``` 3. Add the new tool method (place at the end of the class, before `ToDto`): ```csharp [McpServerTool, Description("List all known tags. Useful for discovering existing tag names (including 'agent' which marks tasks for auto-execution) before tagging.")] public async Task> ListTags(CancellationToken cancellationToken) { var tags = await _tags.GetAllAsync(cancellationToken); return tags.Select(t => new TagDto(t.Id, t.Name)).ToList(); } ``` - [ ] **Step 4: Verify production build + new test compiles** ```bash dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj ``` Expected: `Build succeeded. 0 Error(s)`. ```bash dotnet build tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj 2>&1 | grep "ExternalMcpService" ``` Expected: no errors mentioning `ExternalMcpService` or `ListTags`. (Unrelated `PlanningChainCoordinator` errors persist.) - [ ] **Step 5: Commit** ```bash git add src/ClaudeDo.Worker/External/ExternalMcpService.cs tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs git commit -m "feat(mcp/external): add ListTags + inject TagRepository" ``` --- ### Task 4: Extend `AddTask` to accept `tags` **Files:** - Modify: `src/ClaudeDo.Worker/External/ExternalMcpService.cs` (`AddTask` method) - Modify: `tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs` - [ ] **Step 1: Write the failing tests** Append to `ExternalMcpServiceTests.cs`: ```csharp [Fact] public async Task AddTask_WithTags_AttachesTags() { var listId = await SeedListAsync(); using var queue = /* same construction as ListTags test */; var sut = BuildSut(queue); var dto = await sut.AddTask( listId, "scope-creep handoff", "desc", "claude-cli", queueImmediately: false, tags: new[] { "agent", "custom" }, CancellationToken.None); var tags = await _tasks.GetTagsAsync(dto.Id); Assert.Contains(tags, t => t.Name == "agent"); Assert.Contains(tags, t => t.Name == "custom"); } [Fact] public async Task AddTask_NullTags_BehavesAsBefore() { var listId = await SeedListAsync(); using var queue = /* same construction */; var sut = BuildSut(queue); var dto = await sut.AddTask( listId, "no tags", null, "claude-cli", queueImmediately: false, tags: null, CancellationToken.None); Assert.Empty(await _tasks.GetTagsAsync(dto.Id)); } ``` (Replace the `/* same construction */` placeholder with the actual `QueueService` construction used in Task 3 — repeat the code, do not extract.) - [ ] **Step 2: Run test to verify it fails** ```bash dotnet build tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj 2>&1 | grep "AddTask" ``` Expected: error that `AddTask` does not accept a 7th `tags` parameter. - [ ] **Step 3: Implement** Replace the existing `AddTask` method in `ExternalMcpService.cs` with: ```csharp [McpServerTool, Description("Create a new task in the given list. Set queueImmediately=true to enqueue it for agent execution. Optional tags are attached on creation; missing tag names auto-create.")] public async Task AddTask( string listId, string title, string? description, string createdBy, bool queueImmediately, IReadOnlyList? tags, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(listId)) throw new InvalidOperationException("listId is required."); if (string.IsNullOrWhiteSpace(title)) throw new InvalidOperationException("title is required."); if (string.IsNullOrWhiteSpace(createdBy)) throw new InvalidOperationException("createdBy is required."); var list = await _lists.GetByIdAsync(listId, cancellationToken) ?? throw new InvalidOperationException($"List {listId} not found."); var entity = new TaskEntity { Id = Guid.NewGuid().ToString(), ListId = listId, Title = title, Description = description, Status = queueImmediately ? TaskStatus.Queued : TaskStatus.Manual, CreatedAt = DateTime.UtcNow, CommitType = list.DefaultCommitType, CreatedBy = createdBy, }; await _tasks.AddAsync(entity, cancellationToken); if (tags is not null && tags.Count > 0) await _tasks.SetTagsAsync(entity.Id, tags, cancellationToken); if (queueImmediately) _queue.WakeQueue(); await _broadcaster.TaskUpdated(entity.Id); return ToDto(entity); } ``` - [ ] **Step 4: Verify production build** ```bash dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj ``` Expected: `Build succeeded. 0 Error(s)`. - [ ] **Step 5: Commit** ```bash git add src/ClaudeDo.Worker/External/ExternalMcpService.cs tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs git commit -m "feat(mcp/external): AddTask accepts tags on creation" ``` --- ### Task 5: `UpdateTask` **Files:** - Modify: `src/ClaudeDo.Worker/External/ExternalMcpService.cs` - Modify: `tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs` - [ ] **Step 1: Write the failing tests** Append to `ExternalMcpServiceTests.cs`: ```csharp [Fact] public async Task UpdateTask_PatchesNonNullFieldsOnly() { var listId = await SeedListAsync(); var task = await SeedTaskAsync(listId, "old title"); using var queue = /* same construction */; var sut = BuildSut(queue); var dto = await sut.UpdateTask(task.Id, "new title", null, null, null, CancellationToken.None); Assert.Equal("new title", dto.Title); var loaded = await _tasks.GetByIdAsync(task.Id); Assert.Equal("new title", loaded!.Title); } [Fact] public async Task UpdateTask_TagsReplaceFullSet() { var listId = await SeedListAsync(); var task = await SeedTaskAsync(listId); await _tasks.SetTagsAsync(task.Id, new[] { "agent" }); using var queue = /* same construction */; var sut = BuildSut(queue); await sut.UpdateTask(task.Id, null, null, null, new[] { "manual" }, CancellationToken.None); var tags = await _tasks.GetTagsAsync(task.Id); Assert.Single(tags); Assert.Equal("manual", tags[0].Name); } [Fact] public async Task UpdateTask_OnRunning_Throws() { var listId = await SeedListAsync(); var task = await SeedTaskAsync(listId, status: TaskStatus.Running); using var queue = /* same construction */; var sut = BuildSut(queue); await Assert.ThrowsAsync(() => sut.UpdateTask(task.Id, "x", null, null, null, CancellationToken.None)); } [Fact] public async Task UpdateTask_NotFound_Throws() { using var queue = /* same construction */; var sut = BuildSut(queue); await Assert.ThrowsAsync(() => sut.UpdateTask("does-not-exist", "x", null, null, null, CancellationToken.None)); } ``` - [ ] **Step 2: Run test to verify it fails** ```bash dotnet build tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj 2>&1 | grep "UpdateTask" ``` Expected: errors that `UpdateTask` does not exist on `ExternalMcpService`. - [ ] **Step 3: Implement** Add to `ExternalMcpService.cs` (after `AddTask`): ```csharp [McpServerTool, Description("Update an existing task's title, description, commit type, and/or tags. Pass null to leave a field unchanged. Tags are replaced as a full set when non-null. Refuses if the task is currently Running.")] public async Task UpdateTask( string taskId, string? title, string? description, string? commitType, IReadOnlyList? tags, CancellationToken cancellationToken) { var task = await _tasks.GetByIdAsync(taskId, cancellationToken) ?? throw new InvalidOperationException($"Task {taskId} not found."); if (task.Status == TaskStatus.Running) throw new InvalidOperationException("Cannot update a running task. Cancel it first."); if (title is not null) task.Title = title; if (description is not null) task.Description = description; if (commitType is not null) task.CommitType = commitType; await _tasks.UpdateAsync(task, cancellationToken); if (tags is not null) await _tasks.SetTagsAsync(taskId, tags, cancellationToken); var reload = (await _tasks.GetByIdAsync(taskId, cancellationToken))!; await _broadcaster.TaskUpdated(taskId); return ToDto(reload); } ``` - [ ] **Step 4: Verify production build** ```bash dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj ``` Expected: `Build succeeded. 0 Error(s)`. - [ ] **Step 5: Commit** ```bash git add src/ClaudeDo.Worker/External/ExternalMcpService.cs tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs git commit -m "feat(mcp/external): add UpdateTask for content/tag patching" ``` --- ### Task 6: `DeleteTask` **Files:** - Modify: `src/ClaudeDo.Worker/External/ExternalMcpService.cs` - Modify: `tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs` - [ ] **Step 1: Write the failing tests** Append to `ExternalMcpServiceTests.cs`: ```csharp [Fact] public async Task DeleteTask_RemovesTaskAndTagJoins() { var listId = await SeedListAsync(); var task = await SeedTaskAsync(listId); await _tasks.SetTagsAsync(task.Id, new[] { "agent" }); using var queue = /* same construction */; var sut = BuildSut(queue); await sut.DeleteTask(task.Id, CancellationToken.None); Assert.Null(await _tasks.GetByIdAsync(task.Id)); } [Fact] public async Task DeleteTask_OnRunning_Throws() { var listId = await SeedListAsync(); var task = await SeedTaskAsync(listId, status: TaskStatus.Running); using var queue = /* same construction */; var sut = BuildSut(queue); await Assert.ThrowsAsync(() => sut.DeleteTask(task.Id, CancellationToken.None)); } [Fact] public async Task DeleteTask_NotFound_Throws() { using var queue = /* same construction */; var sut = BuildSut(queue); await Assert.ThrowsAsync(() => sut.DeleteTask("does-not-exist", CancellationToken.None)); } ``` - [ ] **Step 2: Run test to verify it fails** ```bash dotnet build tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj 2>&1 | grep "DeleteTask" ``` Expected: errors that `DeleteTask` does not exist on `ExternalMcpService`. - [ ] **Step 3: Implement** Add to `ExternalMcpService.cs` (after `UpdateTask`): ```csharp [McpServerTool, Description("Delete a task. Refuses if the task is currently Running — cancel it first.")] public async Task DeleteTask(string taskId, CancellationToken cancellationToken) { var task = await _tasks.GetByIdAsync(taskId, cancellationToken) ?? throw new InvalidOperationException($"Task {taskId} not found."); if (task.Status == TaskStatus.Running) throw new InvalidOperationException("Cannot delete a running task. Cancel it first."); await _tasks.DeleteAsync(taskId, cancellationToken); await _broadcaster.TaskUpdated(taskId); } ``` - [ ] **Step 4: Verify production build** ```bash dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj ``` Expected: `Build succeeded. 0 Error(s)`. - [ ] **Step 5: Commit** ```bash git add src/ClaudeDo.Worker/External/ExternalMcpService.cs tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs git commit -m "feat(mcp/external): add DeleteTask" ``` --- ### Task 7: `SetTaskTags` **Files:** - Modify: `src/ClaudeDo.Worker/External/ExternalMcpService.cs` - Modify: `tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs` - [ ] **Step 1: Write the failing tests** Append to `ExternalMcpServiceTests.cs`: ```csharp [Fact] public async Task SetTaskTags_ReplacesTagSetAndBroadcasts() { var listId = await SeedListAsync(); var task = await SeedTaskAsync(listId); await _tasks.SetTagsAsync(task.Id, new[] { "agent" }); using var queue = /* same construction */; var sut = BuildSut(queue); var dto = await sut.SetTaskTags(task.Id, new[] { "manual" }, CancellationToken.None); var tags = await _tasks.GetTagsAsync(task.Id); Assert.Single(tags); Assert.Equal("manual", tags[0].Name); Assert.Contains(_hub.RecordingClients.Proxy.Calls, c => c.Method == "TaskUpdated" && (string)c.Args[0]! == task.Id); } [Fact] public async Task SetTaskTags_OnRunning_Throws() { var listId = await SeedListAsync(); var task = await SeedTaskAsync(listId, status: TaskStatus.Running); using var queue = /* same construction */; var sut = BuildSut(queue); await Assert.ThrowsAsync(() => sut.SetTaskTags(task.Id, new[] { "manual" }, CancellationToken.None)); } ``` - [ ] **Step 2: Run test to verify it fails** ```bash dotnet build tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj 2>&1 | grep "SetTaskTags" ``` Expected: errors that `SetTaskTags` does not exist. - [ ] **Step 3: Implement** Add to `ExternalMcpService.cs` (after `DeleteTask`): ```csharp [McpServerTool, Description("Replace the full tag set on an existing task. Missing tag names auto-create. Refuses if the task is Running.")] public async Task SetTaskTags( string taskId, IReadOnlyList tags, CancellationToken cancellationToken) { var task = await _tasks.GetByIdAsync(taskId, cancellationToken) ?? throw new InvalidOperationException($"Task {taskId} not found."); if (task.Status == TaskStatus.Running) throw new InvalidOperationException("Cannot retag a running task. Cancel it first."); await _tasks.SetTagsAsync(taskId, tags, cancellationToken); var reload = (await _tasks.GetByIdAsync(taskId, cancellationToken))!; await _broadcaster.TaskUpdated(taskId); return ToDto(reload); } ``` - [ ] **Step 4: Verify production build** ```bash dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj ``` Expected: `Build succeeded. 0 Error(s)`. - [ ] **Step 5: Commit** ```bash git add src/ClaudeDo.Worker/External/ExternalMcpService.cs tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs git commit -m "feat(mcp/external): add SetTaskTags" ``` --- ### Task 8: Final verification + docs touch **Files:** - Modify: `src/ClaudeDo.Worker/CLAUDE.md` (one-line update reflecting the new tools) - [ ] **Step 1: Full production build** ```bash dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj dotnet build src/ClaudeDo.Data/ClaudeDo.Data.csproj ``` Expected: both succeed with 0 errors. - [ ] **Step 2: Update Worker CLAUDE.md** In `src/ClaudeDo.Worker/CLAUDE.md`, locate the existing line near the bottom of the file describing external MCP tools (search for `ExternalMcpService` or `External/`). If a list of tools is already there, append the new tool names: `UpdateTask`, `DeleteTask`, `SetTaskTags`, `ListTags`. If no such line exists, add one short line under an existing structural section, for example under "Architecture": ```markdown - **External/ExternalMcpService** — always-on MCP tools for general Claude sessions: `ListTaskLists`, `ListTasks`, `GetTask`, `AddTask` (with tags), `UpdateTask`, `UpdateTaskStatus`, `SetTaskTags`, `ListTags`, `DeleteTask`, `RunTaskNow`, `CancelTask`. Auth via optional `X-ClaudeDo-Key` header. ``` If the file already has a similar line — replace it; do not duplicate. - [ ] **Step 3: Verify the full test assembly state is unchanged** ```bash dotnet build tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj 2>&1 | grep -E "error CS" | grep -v "PlanningChainCoordinator\|TaskRunner.*chain\|WorkerHub.*planningChain" ``` Expected: empty output (every remaining error must be one of the pre-existing `PlanningChainCoordinator`-related errors and nothing new). - [ ] **Step 4: When the unrelated refactor lands, run the new tests** (Defer to whoever lands the `PlanningChainCoordinator` refactor — they should run:) ```bash dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj --filter "FullyQualifiedName~ExternalMcpServiceTests|FullyQualifiedName~TaskRepositoryTests.SetTagsAsync" ``` Expected: all new tests green. - [ ] **Step 5: Commit** ```bash git add src/ClaudeDo.Worker/CLAUDE.md git commit -m "docs(worker): document new external MCP tools" ``` --- ## Self-review **Spec coverage:** - `AddTask` extension with tags → Task 4 ✓ - `UpdateTask` → Task 5 ✓ - `DeleteTask` → Task 6 ✓ - `SetTaskTags` → Task 7 ✓ - `ListTags` → Task 3 ✓ - `TaskRepository.SetTagsAsync` → Task 1 ✓ - Auth (no change) → out of scope, called out in pre-flight ✓ - Tests for each tool → Tasks 1, 3-7 ✓ - Docs touch → Task 8 ✓ **Placeholder scan:** The phrase `/* same construction */` in tasks 4–7 is intentional — the engineer fills it in by mirroring the `QueueService` construction in Task 3 (which itself mirrors `QueueServiceTests.cs`). All other placeholders eliminated. No "TBD". **Type consistency:** - `IReadOnlyList` for tag inputs everywhere ✓ - `TaskDto` returned by `AddTask`, `UpdateTask`, `SetTaskTags` ✓ - `TagDto(long Id, string Name)` consistent across `ListTags` ✓ - Constructor signature `(TaskRepository, ListRepository, QueueService, HubBroadcaster, TagRepository)` consistent between Task 3 implementation and Task 2 scaffold's `BuildSut` call ✓ - Method `TaskRepository.SetTagsAsync(string, IReadOnlyList, CancellationToken)` consistent with all callers ✓ No issues found.