# 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):**
- `ListRepository` — `AddAsync`, `UpdateAsync`, `DeleteAsync`, `GetByIdAsync`, `GetAllAsync`, `GetConfigAsync`, `SetConfigAsync`, `DeleteConfigAsync`
- `TaskRepository.UpdateAgentSettingsAsync(taskId, model?, systemPrompt?, agentPath?)`
- `TaskRunRepository` — `GetByTaskIdAsync`, `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**

```csharp
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**

```csharp
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**

```bash
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**

```csharp
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**

```csharp
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**

```bash
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**

```csharp
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**

```csharp
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**

```bash
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**

```csharp
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**

```bash
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**

```csharp
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**

```csharp
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**

```bash
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**

```csharp
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**

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

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

```csharp
    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**

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

```markdown
- **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**

```bash
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`).
