Files
ClaudeDo/docs/superpowers/plans/2026-05-30-external-mcp-ui-parity.md
2026-05-30 13:42:24 +02:00

37 KiB

External MCP — UI Parity (Start & Observe) 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: Add MCP tools so an external Claude session can fully start and observe ClaudeDo sessions (list/config management, run history, logs, agent listing, reset-failed, app-settings read), reaching UI parity for those concerns.

Architecture: New focused [McpServerToolType] classes in src/ClaudeDo.Worker/External/, each injecting an existing worker service (no logic duplication). All registered in the external WebApplication DI container in Program.cs. Mutations broadcast the same SignalR events the hub raises, keeping the UI in sync.

Tech Stack: .NET 8, ModelContextProtocol.Server, EF Core (SQLite), xUnit integration tests (real SQLite via DbFixture).

Build/test note (from project memory): dotnet build ClaudeDo.slnx fails on .NET 8. Build the csproj directly: dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj Test: dotnet test tests/ClaudeDo.Worker.Tests


File Structure

Create:

  • src/ClaudeDo.Worker/External/ListMcpTools.cs — list create/update/delete tools
  • src/ClaudeDo.Worker/External/ConfigMcpTools.cs — list-config + task-config tools + DTO
  • src/ClaudeDo.Worker/External/RunHistoryMcpTools.cs — run history + log read tools + DTO
  • src/ClaudeDo.Worker/External/AgentMcpTools.cs — agent listing tool
  • src/ClaudeDo.Worker/External/LifecycleMcpTools.cs — reset-failed-task tool
  • src/ClaudeDo.Worker/External/AppSettingsMcpTools.cs — app-settings read tool
  • tests/ClaudeDo.Worker.Tests/External/ListMcpToolsTests.cs
  • tests/ClaudeDo.Worker.Tests/External/ConfigMcpToolsTests.cs
  • tests/ClaudeDo.Worker.Tests/External/RunHistoryMcpToolsTests.cs
  • tests/ClaudeDo.Worker.Tests/External/LifecycleMcpToolsTests.cs

Modify:

  • src/ClaudeDo.Worker/Program.cs:188-217 — register new tool classes + services in the external builder
  • src/ClaudeDo.Worker/CLAUDE.md:27 — remove stale tag tools, refresh the External MCP tool inventory

Reference (existing, do not change):

  • ListRepositoryAddAsync, UpdateAsync, DeleteAsync, GetByIdAsync, GetAllAsync, GetConfigAsync, SetConfigAsync, DeleteConfigAsync
  • TaskRepository.UpdateAgentSettingsAsync(taskId, model?, systemPrompt?, agentPath?)
  • TaskRunRepositoryGetByTaskIdAsync, GetByIdAsync, GetLatestByTaskIdAsync
  • TaskResetService.ResetAsync(taskId, ct) — refuses Running, discards worktree, resets to Idle
  • AgentFileService.ScanAsync(ct)List<AgentInfo>; AgentInfo(string Name, string Description, string Path)
  • AppSettingsRepository.GetAsync()AppSettingsEntity
  • TaskRunEntity fields: Id, TaskId, RunNumber, SessionId, IsRetry, ResultMarkdown, StructuredOutputJson, ErrorMarkdown, ExitCode, TurnCount, TokensIn, TokensOut, LogPath, StartedAt, FinishedAt
  • CommitTypeRegistry.DefaultType
  • HubBroadcaster.ListUpdated(id), .TaskUpdated(id)

Spec refinement (YAGNI): the spec listed an agent "refresh" tool. AgentFileService.ScanAsync reads disk fresh on every call, so a separate refresh is redundant for an MCP client. We implement ListAgents only.


Task 1: List management tools (ListMcpTools)

Files:

  • Create: src/ClaudeDo.Worker/External/ListMcpTools.cs

  • Test: tests/ClaudeDo.Worker.Tests/External/ListMcpToolsTests.cs

  • Step 1: Write the failing test

using ClaudeDo.Data;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.External;
using ClaudeDo.Worker.Hub;
using ClaudeDo.Worker.Tests.Infrastructure;
using Microsoft.AspNetCore.SignalR;

namespace ClaudeDo.Worker.Tests.External;

internal sealed class ListToolsHubClients : IHubClients
{
    public ListToolsClientProxy Proxy { get; } = new();
    public IClientProxy All => Proxy;
    public IClientProxy AllExcept(IReadOnlyList<string> e) => Proxy;
    public IClientProxy Client(string c) => Proxy;
    public IClientProxy Clients(IReadOnlyList<string> c) => Proxy;
    public IClientProxy Group(string g) => Proxy;
    public IClientProxy GroupExcept(string g, IReadOnlyList<string> e) => Proxy;
    public IClientProxy Groups(IReadOnlyList<string> g) => Proxy;
    public IClientProxy User(string u) => Proxy;
    public IClientProxy Users(IReadOnlyList<string> u) => Proxy;
}
internal sealed class ListToolsClientProxy : IClientProxy
{
    public Task SendCoreAsync(string m, object?[] a, CancellationToken ct = default) => Task.CompletedTask;
}
internal sealed class ListToolsHubContext : IHubContext<WorkerHub>
{
    public ListToolsHubClients RecordingClients { get; } = new();
    public IHubClients Clients => RecordingClients;
    public IGroupManager Groups => throw new NotImplementedException();
}

public sealed class ListMcpToolsTests : IDisposable
{
    private readonly DbFixture _db = new();
    private readonly ClaudeDoDbContext _ctx;
    private readonly ListRepository _lists;
    private readonly ListMcpTools _sut;

    public ListMcpToolsTests()
    {
        _ctx = _db.CreateContext();
        _lists = new ListRepository(_ctx);
        _sut = new ListMcpTools(_lists, new HubBroadcaster(new ListToolsHubContext()));
    }

    public void Dispose() { _ctx.Dispose(); _db.Dispose(); }

    [Fact]
    public async Task CreateList_PersistsWithDefaults()
    {
        var dto = await _sut.CreateList("My List", null, null, CancellationToken.None);

        Assert.Equal("My List", dto.Name);
        var loaded = await _lists.GetByIdAsync(dto.Id);
        Assert.NotNull(loaded);
        Assert.Equal("chore", loaded!.DefaultCommitType);
    }

    [Fact]
    public async Task UpdateList_PatchesNameWorkingDirAndCommitType()
    {
        var created = await _sut.CreateList("orig", null, null, CancellationToken.None);

        var dto = await _sut.UpdateList(created.Id, "renamed", "C:/work", "feat", CancellationToken.None);

        Assert.Equal("renamed", dto.Name);
        Assert.Equal("C:/work", dto.WorkingDir);
        var loaded = await _lists.GetByIdAsync(created.Id);
        Assert.Equal("feat", loaded!.DefaultCommitType);
    }

    [Fact]
    public async Task UpdateList_NotFound_Throws()
    {
        await Assert.ThrowsAsync<InvalidOperationException>(() =>
            _sut.UpdateList("missing", "x", null, null, CancellationToken.None));
    }

    [Fact]
    public async Task DeleteList_RemovesList()
    {
        var created = await _sut.CreateList("gone", null, null, CancellationToken.None);

        await _sut.DeleteList(created.Id, CancellationToken.None);

        Assert.Null(await _lists.GetByIdAsync(created.Id));
    }
}
  • Step 2: Run test to verify it fails

Run: dotnet test tests/ClaudeDo.Worker.Tests --filter ListMcpToolsTests Expected: FAIL — ListMcpTools does not exist (compile error).

  • Step 3: Write minimal implementation
using System.ComponentModel;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Hub;
using ModelContextProtocol.Server;

namespace ClaudeDo.Worker.External;

public sealed record ListSummaryDto(string Id, string Name, string? WorkingDir, string DefaultCommitType);

[McpServerToolType]
public sealed class ListMcpTools
{
    private readonly ListRepository _lists;
    private readonly HubBroadcaster _broadcaster;

    public ListMcpTools(ListRepository lists, HubBroadcaster broadcaster)
    {
        _lists = lists;
        _broadcaster = broadcaster;
    }

    [McpServerTool, Description("Create a new task list. workingDir sets the git repo tasks run against; commitType defaults to 'chore'.")]
    public async Task<ListSummaryDto> CreateList(
        string name, string? workingDir, string? commitType, CancellationToken cancellationToken)
    {
        if (string.IsNullOrWhiteSpace(name))
            throw new InvalidOperationException("name is required.");

        var entity = new ListEntity
        {
            Id = Guid.NewGuid().ToString(),
            Name = name,
            WorkingDir = string.IsNullOrWhiteSpace(workingDir) ? null : workingDir,
            DefaultCommitType = string.IsNullOrWhiteSpace(commitType) ? CommitTypeRegistry.DefaultType : commitType,
            CreatedAt = DateTime.UtcNow,
        };
        await _lists.AddAsync(entity, cancellationToken);
        await _broadcaster.ListUpdated(entity.Id);
        return ToDto(entity);
    }

    [McpServerTool, Description("Rename a list and/or change its working dir and default commit type. Pass null to leave a field unchanged.")]
    public async Task<ListSummaryDto> UpdateList(
        string listId, string? name, string? workingDir, string? commitType, CancellationToken cancellationToken)
    {
        var entity = await _lists.GetByIdAsync(listId, cancellationToken)
            ?? throw new InvalidOperationException($"List {listId} not found.");

        if (name is not null) entity.Name = name;
        if (workingDir is not null)
            entity.WorkingDir = string.IsNullOrWhiteSpace(workingDir) ? null : workingDir;
        if (commitType is not null)
            entity.DefaultCommitType = string.IsNullOrWhiteSpace(commitType) ? CommitTypeRegistry.DefaultType : commitType;

        await _lists.UpdateAsync(entity, cancellationToken);
        await _broadcaster.ListUpdated(listId);
        return ToDto(entity);
    }

    [McpServerTool, Description("Delete a list and its tasks. Irreversible.")]
    public async Task DeleteList(string listId, CancellationToken cancellationToken)
    {
        _ = await _lists.GetByIdAsync(listId, cancellationToken)
            ?? throw new InvalidOperationException($"List {listId} not found.");
        await _lists.DeleteAsync(listId, cancellationToken);
        await _broadcaster.ListUpdated(listId);
    }

    private static ListSummaryDto ToDto(ListEntity l) =>
        new(l.Id, l.Name, l.WorkingDir, l.DefaultCommitType);
}

If CommitTypeRegistry is not in scope, add using ClaudeDo.Data; (verify its namespace with a quick grep before assuming).

  • Step 4: Run test to verify it passes

Run: dotnet test tests/ClaudeDo.Worker.Tests --filter ListMcpToolsTests Expected: PASS (4 tests).

  • Step 5: Commit
git add src/ClaudeDo.Worker/External/ListMcpTools.cs tests/ClaudeDo.Worker.Tests/External/ListMcpToolsTests.cs
git commit -m "feat(worker): add external MCP list-management tools"

Task 2: List & task config tools (ConfigMcpTools)

Files:

  • Create: src/ClaudeDo.Worker/External/ConfigMcpTools.cs

  • Test: tests/ClaudeDo.Worker.Tests/External/ConfigMcpToolsTests.cs

  • Step 1: Write the failing test

using ClaudeDo.Data;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.External;
using ClaudeDo.Worker.Hub;
using ClaudeDo.Worker.Tests.Infrastructure;

namespace ClaudeDo.Worker.Tests.External;

public sealed class ConfigMcpToolsTests : IDisposable
{
    private readonly DbFixture _db = new();
    private readonly ClaudeDoDbContext _ctx;
    private readonly ListRepository _lists;
    private readonly TaskRepository _tasks;
    private readonly ConfigMcpTools _sut;

    public ConfigMcpToolsTests()
    {
        _ctx = _db.CreateContext();
        _lists = new ListRepository(_ctx);
        _tasks = new TaskRepository(_ctx);
        _sut = new ConfigMcpTools(_lists, _tasks, new HubBroadcaster(new ListToolsHubContext()));
    }

    public void Dispose() { _ctx.Dispose(); _db.Dispose(); }

    private async Task<string> SeedListAsync()
    {
        var id = Guid.NewGuid().ToString();
        await _lists.AddAsync(new ListEntity { Id = id, Name = "L", CreatedAt = DateTime.UtcNow });
        return id;
    }

    [Fact]
    public async Task SetAndGetListConfig_RoundTrips()
    {
        var listId = await SeedListAsync();

        await _sut.SetListConfig(listId, "sonnet", "be terse", null, CancellationToken.None);
        var cfg = await _sut.GetListConfig(listId, CancellationToken.None);

        Assert.NotNull(cfg);
        Assert.Equal("sonnet", cfg!.Model);
        Assert.Equal("be terse", cfg.SystemPrompt);
        Assert.Null(cfg.AgentPath);
    }

    [Fact]
    public async Task SetListConfig_AllNull_ClearsConfig()
    {
        var listId = await SeedListAsync();
        await _sut.SetListConfig(listId, "sonnet", null, null, CancellationToken.None);

        await _sut.SetListConfig(listId, null, null, null, CancellationToken.None);

        Assert.Null(await _sut.GetListConfig(listId, CancellationToken.None));
    }

    [Fact]
    public async Task SetTaskConfig_PersistsOverrides()
    {
        var listId = await SeedListAsync();
        var task = new TaskEntity
        {
            Id = Guid.NewGuid().ToString(),
            ListId = listId,
            Title = "t",
            Status = ClaudeDo.Data.Models.TaskStatus.Idle,
            CreatedAt = DateTime.UtcNow,
            CommitType = "chore",
        };
        await _tasks.AddAsync(task);

        await _sut.SetTaskConfig(task.Id, "opus", null, null, CancellationToken.None);

        var loaded = await _tasks.GetByIdAsync(task.Id);
        Assert.Equal("opus", loaded!.Model);
    }
}
  • Step 2: Run test to verify it fails

Run: dotnet test tests/ClaudeDo.Worker.Tests --filter ConfigMcpToolsTests Expected: FAIL — ConfigMcpTools does not exist.

  • Step 3: Write minimal implementation
using System.ComponentModel;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Hub;
using ModelContextProtocol.Server;

namespace ClaudeDo.Worker.External;

public sealed record TaskConfigDto(string? Model, string? SystemPrompt, string? AgentPath);

[McpServerToolType]
public sealed class ConfigMcpTools
{
    private readonly ListRepository _lists;
    private readonly TaskRepository _tasks;
    private readonly HubBroadcaster _broadcaster;

    public ConfigMcpTools(ListRepository lists, TaskRepository tasks, HubBroadcaster broadcaster)
    {
        _lists = lists;
        _tasks = tasks;
        _broadcaster = broadcaster;
    }

    [McpServerTool, Description("Get a list's default config (model, system prompt, agent path). Returns null if no config is set.")]
    public async Task<TaskConfigDto?> GetListConfig(string listId, CancellationToken cancellationToken)
    {
        var cfg = await _lists.GetConfigAsync(listId, cancellationToken);
        return cfg is null ? null : new TaskConfigDto(cfg.Model, cfg.SystemPrompt, cfg.AgentPath);
    }

    [McpServerTool, Description("Set a list's default model/system prompt/agent path. Passing all three as null clears the list config.")]
    public async Task SetListConfig(
        string listId, string? model, string? systemPrompt, string? agentPath, CancellationToken cancellationToken)
    {
        _ = await _lists.GetByIdAsync(listId, cancellationToken)
            ?? throw new InvalidOperationException($"List {listId} not found.");

        var m = Nullify(model);
        var sp = Nullify(systemPrompt);
        var ap = Nullify(agentPath);

        if (m is null && sp is null && ap is null)
            await _lists.DeleteConfigAsync(listId, cancellationToken);
        else
            await _lists.SetConfigAsync(new ListConfigEntity
            {
                ListId = listId, Model = m, SystemPrompt = sp, AgentPath = ap,
            }, cancellationToken);

        await _broadcaster.ListUpdated(listId);
    }

    [McpServerTool, Description("Set per-task config overrides (model/system prompt/agent path). Pass null to clear a field.")]
    public async Task SetTaskConfig(
        string taskId, string? model, string? systemPrompt, string? agentPath, CancellationToken cancellationToken)
    {
        _ = await _tasks.GetByIdAsync(taskId, cancellationToken)
            ?? throw new InvalidOperationException($"Task {taskId} not found.");

        await _tasks.UpdateAgentSettingsAsync(taskId, Nullify(model), Nullify(systemPrompt), Nullify(agentPath), cancellationToken);
        await _broadcaster.TaskUpdated(taskId);
    }

    private static string? Nullify(string? s) => string.IsNullOrWhiteSpace(s) ? null : s;
}

Verify UpdateAgentSettingsAsync accepts a CancellationToken (read TaskRepository.cs:157). If it does not, drop the cancellationToken argument from that call.

  • Step 4: Run test to verify it passes

Run: dotnet test tests/ClaudeDo.Worker.Tests --filter ConfigMcpToolsTests Expected: PASS (3 tests).

  • Step 5: Commit
git add src/ClaudeDo.Worker/External/ConfigMcpTools.cs tests/ClaudeDo.Worker.Tests/External/ConfigMcpToolsTests.cs
git commit -m "feat(worker): add external MCP list/task config tools"

Task 3: Run history & log tools (RunHistoryMcpTools)

Files:

  • Create: src/ClaudeDo.Worker/External/RunHistoryMcpTools.cs

  • Test: tests/ClaudeDo.Worker.Tests/External/RunHistoryMcpToolsTests.cs

  • Step 1: Write the failing test

using ClaudeDo.Data;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.External;
using ClaudeDo.Worker.Tests.Infrastructure;

namespace ClaudeDo.Worker.Tests.External;

public sealed class RunHistoryMcpToolsTests : IDisposable
{
    private readonly DbFixture _db = new();
    private readonly ClaudeDoDbContext _ctx;
    private readonly TaskRunRepository _runs;
    private readonly RunHistoryMcpTools _sut;

    public RunHistoryMcpToolsTests()
    {
        _ctx = _db.CreateContext();
        _runs = new TaskRunRepository(_ctx);
        _sut = new RunHistoryMcpTools(_runs);
    }

    public void Dispose() { _ctx.Dispose(); _db.Dispose(); }

    private async Task SeedTaskAsync(string taskId)
    {
        var lists = new ListRepository(_ctx);
        var tasks = new TaskRepository(_ctx);
        var listId = Guid.NewGuid().ToString();
        await lists.AddAsync(new ListEntity { Id = listId, Name = "L", CreatedAt = DateTime.UtcNow });
        await tasks.AddAsync(new TaskEntity
        {
            Id = taskId, ListId = listId, Title = "t",
            Status = ClaudeDo.Data.Models.TaskStatus.Done, CreatedAt = DateTime.UtcNow, CommitType = "chore",
        });
    }

    [Fact]
    public async Task ListRuns_ReturnsProjectedRuns()
    {
        var taskId = Guid.NewGuid().ToString();
        await SeedTaskAsync(taskId);
        await _runs.AddAsync(new TaskRunEntity
        {
            Id = Guid.NewGuid().ToString(), TaskId = taskId, RunNumber = 1,
            IsRetry = false, Prompt = "p", ResultMarkdown = "done", TokensIn = 10, TokensOut = 20,
        });

        var list = await _sut.ListRuns(taskId, CancellationToken.None);

        Assert.Single(list);
        Assert.Equal("done", list[0].ResultMarkdown);
        Assert.Equal(10, list[0].TokensIn);
    }

    [Fact]
    public async Task GetTaskLog_NoLog_Throws()
    {
        var taskId = Guid.NewGuid().ToString();
        await SeedTaskAsync(taskId);

        await Assert.ThrowsAsync<InvalidOperationException>(() =>
            _sut.GetTaskLog(taskId, CancellationToken.None));
    }

    [Fact]
    public async Task GetTaskLog_ReadsLatestRunLogFile()
    {
        var taskId = Guid.NewGuid().ToString();
        await SeedTaskAsync(taskId);
        var logPath = Path.Combine(Path.GetTempPath(), $"claudedo_log_{Guid.NewGuid():N}.txt");
        await File.WriteAllTextAsync(logPath, "hello log");
        await _runs.AddAsync(new TaskRunEntity
        {
            Id = Guid.NewGuid().ToString(), TaskId = taskId, RunNumber = 1,
            IsRetry = false, Prompt = "p", LogPath = logPath,
        });

        var content = await _sut.GetTaskLog(taskId, CancellationToken.None);

        Assert.Equal("hello log", content);
        File.Delete(logPath);
    }
}
  • Step 2: Run test to verify it fails

Run: dotnet test tests/ClaudeDo.Worker.Tests --filter RunHistoryMcpToolsTests Expected: FAIL — RunHistoryMcpTools does not exist.

  • Step 3: Write minimal implementation
using System.ComponentModel;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ModelContextProtocol.Server;

namespace ClaudeDo.Worker.External;

public sealed record RunDto(
    string Id, int RunNumber, string? SessionId, bool IsRetry,
    string? ResultMarkdown, string? StructuredOutputJson, string? ErrorMarkdown,
    int? ExitCode, int? TurnCount, int? TokensIn, int? TokensOut,
    DateTime? StartedAt, DateTime? FinishedAt);

[McpServerToolType]
public sealed class RunHistoryMcpTools
{
    private readonly TaskRunRepository _runs;

    public RunHistoryMcpTools(TaskRunRepository runs) => _runs = runs;

    [McpServerTool, Description("List all execution runs for a task (newest run metadata, tokens, turns, result, error).")]
    public async Task<IReadOnlyList<RunDto>> ListRuns(string taskId, CancellationToken cancellationToken)
    {
        var runs = await _runs.GetByTaskIdAsync(taskId, cancellationToken);
        return runs.Select(ToDto).ToList();
    }

    [McpServerTool, Description("Get a single execution run by its run id.")]
    public async Task<RunDto> GetRun(string runId, CancellationToken cancellationToken)
    {
        var run = await _runs.GetByIdAsync(runId, cancellationToken)
            ?? throw new InvalidOperationException($"Run {runId} not found.");
        return ToDto(run);
    }

    [McpServerTool, Description("Fetch the raw log output of a task's latest run. Throws if no log is available.")]
    public async Task<string> GetTaskLog(string taskId, CancellationToken cancellationToken)
    {
        var run = await _runs.GetLatestByTaskIdAsync(taskId, cancellationToken)
            ?? throw new InvalidOperationException($"No runs found for task {taskId}.");
        if (string.IsNullOrWhiteSpace(run.LogPath) || !File.Exists(run.LogPath))
            throw new InvalidOperationException("No log available for the latest run.");
        return await File.ReadAllTextAsync(run.LogPath, cancellationToken);
    }

    private static RunDto ToDto(TaskRunEntity r) => new(
        r.Id, r.RunNumber, r.SessionId, r.IsRetry,
        r.ResultMarkdown, r.StructuredOutputJson, r.ErrorMarkdown,
        r.ExitCode, r.TurnCount, r.TokensIn, r.TokensOut,
        r.StartedAt, r.FinishedAt);
}
  • Step 4: Run test to verify it passes

Run: dotnet test tests/ClaudeDo.Worker.Tests --filter RunHistoryMcpToolsTests Expected: PASS (3 tests).

  • Step 5: Commit
git add src/ClaudeDo.Worker/External/RunHistoryMcpTools.cs tests/ClaudeDo.Worker.Tests/External/RunHistoryMcpToolsTests.cs
git commit -m "feat(worker): add external MCP run-history and log tools"

Task 4: Agent listing tool (AgentMcpTools)

Files:

  • Create: src/ClaudeDo.Worker/External/AgentMcpTools.cs

  • Test: none new — covered indirectly; AgentFileService already has unit coverage. (This tool is a thin pass-through.)

  • Step 1: Write minimal implementation

using System.ComponentModel;
using ClaudeDo.Data.Models;
using ClaudeDo.Worker.Agents;
using ModelContextProtocol.Server;

namespace ClaudeDo.Worker.External;

[McpServerToolType]
public sealed class AgentMcpTools
{
    private readonly AgentFileService _agents;

    public AgentMcpTools(AgentFileService agents) => _agents = agents;

    [McpServerTool, Description("List available agent definition files (name, description, path) for use as a task's agent path.")]
    public async Task<IReadOnlyList<AgentInfo>> ListAgents(CancellationToken cancellationToken)
        => await _agents.ScanAsync(cancellationToken);
}
  • Step 2: Verify it compiles

Run: dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj Expected: Build succeeded.

  • Step 3: Commit
git add src/ClaudeDo.Worker/External/AgentMcpTools.cs
git commit -m "feat(worker): add external MCP agent-listing tool"

Task 5: Reset-failed-task tool (LifecycleMcpTools)

Files:

  • Create: src/ClaudeDo.Worker/External/LifecycleMcpTools.cs
  • Test: tests/ClaudeDo.Worker.Tests/External/LifecycleMcpToolsTests.cs

TaskResetService.ResetAsync already refuses Running tasks and discards the worktree. The MCP tool adds a guard that the task must be Failed (the only sensible reset target via this surface) and delegates.

  • Step 1: Write the failing test
using ClaudeDo.Data;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.External;
using ClaudeDo.Worker.Hub;
using ClaudeDo.Worker.Lifecycle;
using ClaudeDo.Worker.Runner;
using ClaudeDo.Worker.State;
using ClaudeDo.Worker.Tests.Infrastructure;
using ClaudeDo.Worker.Tests.Services;
using ClaudeDo.Data.Git;
using ClaudeDo.Worker.Config;
using Microsoft.Extensions.Logging.Abstractions;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;

namespace ClaudeDo.Worker.Tests.External;

public sealed class LifecycleMcpToolsTests : IDisposable
{
    private readonly DbFixture _db = new();
    private readonly ClaudeDoDbContext _ctx;
    private readonly TaskRepository _tasks;
    private readonly ListRepository _lists;

    public LifecycleMcpToolsTests()
    {
        _ctx = _db.CreateContext();
        _tasks = new TaskRepository(_ctx);
        _lists = new ListRepository(_ctx);
    }

    public void Dispose() { _ctx.Dispose(); _db.Dispose(); }

    private LifecycleMcpTools BuildSut()
    {
        var cfg = new WorkerConfig
        {
            SandboxRoot = Path.Combine(Path.GetTempPath(), $"cd_{Guid.NewGuid():N}"),
            LogRoot = Path.Combine(Path.GetTempPath(), $"cdl_{Guid.NewGuid():N}"),
        };
        var dbFactory = _db.CreateFactory();
        var broadcaster = new HubBroadcaster(new ListToolsHubContext());
        var wtManager = new WorktreeManager(new GitService(), dbFactory, cfg, NullLogger<WorktreeManager>.Instance);
        var state = TaskStateServiceBuilder.Build(dbFactory).State;
        var reset = new TaskResetService(dbFactory, wtManager, broadcaster, state, NullLogger<TaskResetService>.Instance);
        return new LifecycleMcpTools(_tasks, reset);
    }

    private async Task<TaskEntity> SeedTaskAsync(TaskStatus status)
    {
        var listId = Guid.NewGuid().ToString();
        await _lists.AddAsync(new ListEntity { Id = listId, Name = "L", CreatedAt = DateTime.UtcNow });
        var task = new TaskEntity
        {
            Id = Guid.NewGuid().ToString(), ListId = listId, Title = "t",
            Status = status, CreatedAt = DateTime.UtcNow, CommitType = "chore",
        };
        await _tasks.AddAsync(task);
        return task;
    }

    [Fact]
    public async Task ResetFailedTask_OnFailed_ResetsToIdle()
    {
        var task = await SeedTaskAsync(TaskStatus.Failed);
        var sut = BuildSut();

        await sut.ResetFailedTask(task.Id, CancellationToken.None);

        var loaded = await _tasks.GetByIdAsync(task.Id);
        Assert.Equal(TaskStatus.Idle, loaded!.Status);
    }

    [Fact]
    public async Task ResetFailedTask_OnNonFailed_Throws()
    {
        var task = await SeedTaskAsync(TaskStatus.Done);
        var sut = BuildSut();

        await Assert.ThrowsAsync<InvalidOperationException>(() =>
            sut.ResetFailedTask(task.Id, CancellationToken.None));
    }

    [Fact]
    public async Task ResetFailedTask_NotFound_Throws()
    {
        var sut = BuildSut();
        await Assert.ThrowsAsync<InvalidOperationException>(() =>
            sut.ResetFailedTask("missing", CancellationToken.None));
    }
}
  • Step 2: Run test to verify it fails

Run: dotnet test tests/ClaudeDo.Worker.Tests --filter LifecycleMcpToolsTests Expected: FAIL — LifecycleMcpTools does not exist.

  • Step 3: Write minimal implementation
using System.ComponentModel;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Lifecycle;
using ModelContextProtocol.Server;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;

namespace ClaudeDo.Worker.External;

[McpServerToolType]
public sealed class LifecycleMcpTools
{
    private readonly TaskRepository _tasks;
    private readonly TaskResetService _reset;

    public LifecycleMcpTools(TaskRepository tasks, TaskResetService reset)
    {
        _tasks = tasks;
        _reset = reset;
    }

    [McpServerTool, Description("Reset a failed task: discards its worktree and returns it to Idle so it can be run again. Only Failed tasks are accepted.")]
    public async Task ResetFailedTask(string taskId, CancellationToken cancellationToken)
    {
        var task = await _tasks.GetByIdAsync(taskId, cancellationToken)
            ?? throw new InvalidOperationException($"Task {taskId} not found.");
        if (task.Status != TaskStatus.Failed)
            throw new InvalidOperationException($"Task {taskId} is {task.Status}, not Failed. Only failed tasks can be reset via this tool.");

        await _reset.ResetAsync(taskId, cancellationToken);
    }
}
  • Step 4: Run test to verify it passes

Run: dotnet test tests/ClaudeDo.Worker.Tests --filter LifecycleMcpToolsTests Expected: PASS (3 tests). (Git-dependent worktree discard is skipped when no worktree row exists — these tasks have none.)

  • Step 5: Commit
git add src/ClaudeDo.Worker/External/LifecycleMcpTools.cs tests/ClaudeDo.Worker.Tests/External/LifecycleMcpToolsTests.cs
git commit -m "feat(worker): add external MCP reset-failed-task tool"

Task 6: App-settings read tool (AppSettingsMcpTools)

Files:

  • Create: src/ClaudeDo.Worker/External/AppSettingsMcpTools.cs
  • Test: none new — thin read-only pass-through over AppSettingsRepository.GetAsync.

This tool is read-only by design (writing app settings is out of scope). It uses the db factory (registered as a singleton in the external builder) to open a context per call, mirroring the hub's pattern.

  • Step 1: Write minimal implementation
using System.ComponentModel;
using ClaudeDo.Data;
using ClaudeDo.Data.Repositories;
using Microsoft.EntityFrameworkCore;
using ModelContextProtocol.Server;

namespace ClaudeDo.Worker.External;

public sealed record AppSettingsReadDto(
    string DefaultModel, int DefaultMaxTurns, string DefaultPermissionMode,
    string WorktreeStrategy, string? CentralWorktreeRoot,
    bool WorktreeAutoCleanupEnabled, int WorktreeAutoCleanupDays);

[McpServerToolType]
public sealed class AppSettingsMcpTools
{
    private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;

    public AppSettingsMcpTools(IDbContextFactory<ClaudeDoDbContext> dbFactory) => _dbFactory = dbFactory;

    [McpServerTool, Description("Read the worker's app-level defaults (model, max turns, permission mode, worktree strategy). Read-only.")]
    public async Task<AppSettingsReadDto> GetAppSettings(CancellationToken cancellationToken)
    {
        using var ctx = await _dbFactory.CreateDbContextAsync(cancellationToken);
        var row = await new AppSettingsRepository(ctx).GetAsync();
        return new AppSettingsReadDto(
            row.DefaultModel, row.DefaultMaxTurns, row.DefaultPermissionMode,
            row.WorktreeStrategy, row.CentralWorktreeRoot,
            row.WorktreeAutoCleanupEnabled, row.WorktreeAutoCleanupDays);
    }
}

Verify AppSettingsRepository.GetAsync signature (it may take a CancellationToken). Adjust the call if so. Confirm AppSettingsEntity property names match (DefaultModel, DefaultMaxTurns, DefaultPermissionMode, WorktreeStrategy, CentralWorktreeRoot, WorktreeAutoCleanupEnabled, WorktreeAutoCleanupDays) — they are used identically in WorkerHub.GetAppSettings (lines 206-219).

  • Step 2: Verify it compiles

Run: dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj Expected: Build succeeded.

  • Step 3: Commit
git add src/ClaudeDo.Worker/External/AppSettingsMcpTools.cs
git commit -m "feat(worker): add external MCP app-settings read tool"

Task 7: Register new tools in the external MCP app

Files:

  • Modify: src/ClaudeDo.Worker/Program.cs:188-217

The external WebApplication has its own DI container. Each new tool class and every service it needs must be registered there, and each class added via .WithTools<T>().

  • Step 1: Add service + tool registrations

In the if (cfg.ExternalMcpPort > 0) block, after the existing externalBuilder.Services.AddScoped<ExternalMcpService>(); line, add:

    externalBuilder.Services.AddScoped<TaskRunRepository>();
    externalBuilder.Services.AddSingleton(app.Services.GetRequiredService<WorktreeManager>());
    externalBuilder.Services.AddSingleton(app.Services.GetRequiredService<AgentFileService>());
    externalBuilder.Services.AddScoped<TaskResetService>();
    externalBuilder.Services.AddScoped<ListMcpTools>();
    externalBuilder.Services.AddScoped<ConfigMcpTools>();
    externalBuilder.Services.AddScoped<RunHistoryMcpTools>();
    externalBuilder.Services.AddScoped<AgentMcpTools>();
    externalBuilder.Services.AddScoped<LifecycleMcpTools>();
    externalBuilder.Services.AddScoped<AppSettingsMcpTools>();

And extend the AddMcpServer() chain:

    externalBuilder.Services.AddMcpServer()
        .WithHttpTransport()
        .WithTools<ExternalMcpService>()
        .WithTools<ListMcpTools>()
        .WithTools<ConfigMcpTools>()
        .WithTools<RunHistoryMcpTools>()
        .WithTools<AgentMcpTools>()
        .WithTools<LifecycleMcpTools>()
        .WithTools<AppSettingsMcpTools>();

Verify before editing: confirm WorktreeManager and AgentFileService are registered as singletons in the main app container (grep Program.cs for WorktreeManager and AgentFileService). If AgentFileService is constructed with a directory string rather than DI-resolved, register it in the external builder the same way the main app does (e.g. new AgentFileService(agentsDir)), not via GetRequiredService. TaskResetService depends on WorktreeManager, IDbContextFactory, HubBroadcaster, ITaskStateService, ILogger<TaskResetService> — all already singletons in the external builder except WorktreeManager (added above) and the logger (provided by default logging).

  • Step 2: Build the worker

Run: dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj Expected: Build succeeded, no DI-related compile errors.

  • Step 3: Run the full worker test suite

Run: dotnet test tests/ClaudeDo.Worker.Tests Expected: PASS (all existing + new tests).

  • Step 4: Commit
git add src/ClaudeDo.Worker/Program.cs
git commit -m "feat(worker): register new external MCP tool classes"

Task 8: Documentation cleanup

Files:

  • Modify: src/ClaudeDo.Worker/CLAUDE.md:27

  • Step 1: Replace the stale External MCP inventory line

Replace the line beginning - **External/ExternalMcpService** — always-on MCP tools… with an accurate inventory that drops the (non-existent) tag tools and lists the new surface:

- **External/*** — always-on MCP tools for general Claude sessions, organized by concern:
  - `ExternalMcpService` — task CRUD + execution: `ListTaskLists`, `ListTasks`, `GetTask`, `AddTask`, `UpdateTask`, `UpdateTaskStatus` (`Idle`/`Queued`), `RunTaskNow`, `CancelTask`, `DeleteTask`
  - `ListMcpTools``CreateList`, `UpdateList`, `DeleteList`
  - `ConfigMcpTools``GetListConfig`, `SetListConfig`, `SetTaskConfig`
  - `RunHistoryMcpTools``ListRuns`, `GetRun`, `GetTaskLog`
  - `AgentMcpTools``ListAgents`
  - `LifecycleMcpTools``ResetFailedTask`
  - `AppSettingsMcpTools``GetAppSettings` (read-only)
  - Purpose is scoped to *starting* and *observing* sessions — no worktree/merge, multi-turn, planning, or app-settings writes. Auth via optional `X-ClaudeDo-Key` header.
  • Step 2: Commit
git add src/ClaudeDo.Worker/CLAUDE.md
git commit -m "docs(worker): correct external MCP tool inventory, drop removed tags"

Self-Review

Spec coverage:

  • List management → Task 1 ✓
  • List & task config → Task 2 ✓
  • Run history & logs → Task 3 ✓
  • Agents (read-only) → Task 4 ✓
  • Reset failed task → Task 5 ✓
  • App settings (read-only) → Task 6 ✓
  • DI wiring (separate external app) → Task 7 ✓
  • Tag doc cleanup → Task 8 ✓
  • Out-of-scope items (multi-turn, worktree ops, planning, app-settings writes, tags, agent create/edit) → not implemented ✓

Placeholder scan: No TBD/TODO. The three "verify before editing" notes point at real signatures the implementer must confirm (cancellation-token overloads, AgentFileService construction, registry namespaces) — these are verification steps with concrete fallbacks, not placeholders.

Type consistency: ListSummaryDto, TaskConfigDto, RunDto, AppSettingsReadDto defined once and used consistently. AgentInfo reused directly (no new DTO). Tool method names match between implementation, tests, and the Task-8 doc inventory (CreateList/UpdateList/DeleteList, GetListConfig/SetListConfig/SetTaskConfig, ListRuns/GetRun/GetTaskLog, ListAgents, ResetFailedTask, GetAppSettings).