971 lines
37 KiB
Markdown
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`).
|