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

971 lines
37 KiB
Markdown

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