Files
ClaudeDo/docs/superpowers/plans/2026-04-25-external-mcp-crud-extensions.md
Mika Kuns 10b2ca817b docs(superpowers): add external MCP CRUD extensions spec and plan
Capture the design and execution plan for the AddTask/UpdateTask/DeleteTask/
SetTaskTags external MCP work that landed in commits 1a74e1c..59dc1e2.
2026-04-27 10:16:19 +02:00

31 KiB
Raw Blame History

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"):

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):

[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<string>());

    Assert.Empty(await _tasks.GetTagsAsync(task.Id));
}
  • Step 2: Run test to verify it fails
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:

public async Task SetTagsAsync(string taskId, IReadOnlyList<string> 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
dotnet build src/ClaudeDo.Data/ClaudeDo.Data.csproj

Expected: Build succeeded. 0 Error(s).

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
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:

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

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
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):

[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
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:
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;
}
  1. Add a tag DTO above the class (next to TaskListDto):
public sealed record TagDto(long Id, string Name);
  1. Add the new tool method (place at the end of the class, before ToDto):
[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<IReadOnlyList<TagDto>> 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
dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj

Expected: Build succeeded. 0 Error(s).

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
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:

[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
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:

[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<TaskDto> AddTask(
    string listId,
    string title,
    string? description,
    string createdBy,
    bool queueImmediately,
    IReadOnlyList<string>? 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
dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj

Expected: Build succeeded. 0 Error(s).

  • Step 5: Commit
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:

[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<InvalidOperationException>(() =>
        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<InvalidOperationException>(() =>
        sut.UpdateTask("does-not-exist", "x", null, null, null, CancellationToken.None));
}
  • Step 2: Run test to verify it fails
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):

[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<TaskDto> UpdateTask(
    string taskId,
    string? title,
    string? description,
    string? commitType,
    IReadOnlyList<string>? 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
dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj

Expected: Build succeeded. 0 Error(s).

  • Step 5: Commit
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:

[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<InvalidOperationException>(() =>
        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<InvalidOperationException>(() =>
        sut.DeleteTask("does-not-exist", CancellationToken.None));
}
  • Step 2: Run test to verify it fails
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):

[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
dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj

Expected: Build succeeded. 0 Error(s).

  • Step 5: Commit
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:

[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<InvalidOperationException>(() =>
        sut.SetTaskTags(task.Id, new[] { "manual" }, CancellationToken.None));
}
  • Step 2: Run test to verify it fails
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):

[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<TaskDto> SetTaskTags(
    string taskId,
    IReadOnlyList<string> 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
dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj

Expected: Build succeeded. 0 Error(s).

  • Step 5: Commit
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

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":

- **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
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:)

dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj --filter "FullyQualifiedName~ExternalMcpServiceTests|FullyQualifiedName~TaskRepositoryTests.SetTagsAsync"

Expected: all new tests green.

  • Step 5: Commit
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 47 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<string> 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<string>, CancellationToken) consistent with all callers ✓

No issues found.