# 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(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 e) => Proxy; public IClientProxy Client(string c) => Proxy; public IClientProxy Clients(IReadOnlyList c) => Proxy; public IClientProxy Group(string g) => Proxy; public IClientProxy GroupExcept(string g, IReadOnlyList e) => Proxy; public IClientProxy Groups(IReadOnlyList g) => Proxy; public IClientProxy User(string u) => Proxy; public IClientProxy Users(IReadOnlyList u) => Proxy; } internal sealed class ListToolsClientProxy : IClientProxy { public Task SendCoreAsync(string m, object?[] a, CancellationToken ct = default) => Task.CompletedTask; } internal sealed class ListToolsHubContext : IHubContext { 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(() => _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 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 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 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 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(() => _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> 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 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 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> 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.Instance); var state = TaskStateServiceBuilder.Build(dbFactory).State; var reset = new TaskResetService(dbFactory, wtManager, broadcaster, state, NullLogger.Instance); return new LifecycleMcpTools(_tasks, reset); } private async Task 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(() => sut.ResetFailedTask(task.Id, CancellationToken.None)); } [Fact] public async Task ResetFailedTask_NotFound_Throws() { var sut = BuildSut(); await Assert.ThrowsAsync(() => 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 _dbFactory; public AppSettingsMcpTools(IDbContextFactory dbFactory) => _dbFactory = dbFactory; [McpServerTool, Description("Read the worker's app-level defaults (model, max turns, permission mode, worktree strategy). Read-only.")] public async Task 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()`. - [ ] **Step 1: Add service + tool registrations** In the `if (cfg.ExternalMcpPort > 0)` block, after the existing `externalBuilder.Services.AddScoped();` line, add: ```csharp externalBuilder.Services.AddScoped(); externalBuilder.Services.AddSingleton(app.Services.GetRequiredService()); externalBuilder.Services.AddSingleton(app.Services.GetRequiredService()); externalBuilder.Services.AddScoped(); externalBuilder.Services.AddScoped(); externalBuilder.Services.AddScoped(); externalBuilder.Services.AddScoped(); externalBuilder.Services.AddScoped(); externalBuilder.Services.AddScoped(); externalBuilder.Services.AddScoped(); ``` And extend the `AddMcpServer()` chain: ```csharp externalBuilder.Services.AddMcpServer() .WithHttpTransport() .WithTools() .WithTools() .WithTools() .WithTools() .WithTools() .WithTools() .WithTools(); ``` > **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` — 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`).