From 22830d3ea880f921a8d541d99d87c1950a8093bf Mon Sep 17 00:00:00 2001 From: mika kuns Date: Thu, 4 Jun 2026 23:03:07 +0200 Subject: [PATCH] feat(mcp): add add_subtask tool to claudedo MCP --- .../External/ExternalMcpService.cs | 38 ++++++ .../External/AddSubtaskToolTests.cs | 118 ++++++++++++++++++ 2 files changed, 156 insertions(+) create mode 100644 tests/ClaudeDo.Worker.Tests/External/AddSubtaskToolTests.cs diff --git a/src/ClaudeDo.Worker/External/ExternalMcpService.cs b/src/ClaudeDo.Worker/External/ExternalMcpService.cs index abf0cd7..94dbe30 100644 --- a/src/ClaudeDo.Worker/External/ExternalMcpService.cs +++ b/src/ClaudeDo.Worker/External/ExternalMcpService.cs @@ -207,6 +207,44 @@ public sealed class ExternalMcpService return ToDto(reload); } + [McpServerTool, Description( + "Append a subtask (step) to a task. orderNum defaults to the end. " + + "Refuses if the task is currently Running. Subtasks are surfaced to the agent at run time and shown in the task's Steps list.")] + public async Task AddSubtask( + string taskId, + string title, + int? orderNum, + CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(title)) + throw new InvalidOperationException("title is required."); + + await using var ctx = await _dbFactory.CreateDbContextAsync(cancellationToken); + var tasks = new TaskRepository(ctx); + var subtasks = new SubtaskRepository(ctx); + + var task = await tasks.GetByIdAsync(taskId, cancellationToken) + ?? throw new InvalidOperationException($"Task {taskId} not found."); + if (task.Status == TaskStatus.Running) + throw new InvalidOperationException("Cannot add a subtask to a running task. Cancel it first."); + + var existing = await subtasks.GetByTaskIdAsync(taskId, cancellationToken); + var order = orderNum ?? (existing.Count == 0 ? 0 : existing.Max(s => s.OrderNum) + 1); + + await subtasks.AddAsync(new SubtaskEntity + { + Id = Guid.NewGuid().ToString(), + TaskId = taskId, + Title = title.Trim(), + Completed = false, + OrderNum = order, + CreatedAt = DateTime.UtcNow, + }, cancellationToken); + + await _broadcaster.TaskUpdated(taskId); + return ToDto(task); + } + [McpServerTool, Description( "Update a task's status. Only 'Idle' and 'Queued' are permitted externally — " + "use run_task_now or cancel_task for execution control, and review_task to act on a WaitingForReview task. " + diff --git a/tests/ClaudeDo.Worker.Tests/External/AddSubtaskToolTests.cs b/tests/ClaudeDo.Worker.Tests/External/AddSubtaskToolTests.cs new file mode 100644 index 0000000..239d3c4 --- /dev/null +++ b/tests/ClaudeDo.Worker.Tests/External/AddSubtaskToolTests.cs @@ -0,0 +1,118 @@ +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.Queue; +using ClaudeDo.Worker.Runner; +using ClaudeDo.Worker.Tests.Infrastructure; +using ClaudeDo.Worker.Tests.Services; +using ClaudeDo.Worker.Worktrees; +using ClaudeDo.Worker.Config; +using Microsoft.Extensions.Logging.Abstractions; +using TaskStatus = ClaudeDo.Data.Models.TaskStatus; + +namespace ClaudeDo.Worker.Tests.External; + +public sealed class AddSubtaskToolTests : IDisposable +{ + private readonly DbFixture _db = new(); + private readonly ClaudeDoDbContext _ctx; + private readonly TaskRepository _tasks; + private readonly ListRepository _lists; + + public AddSubtaskToolTests() + { + _ctx = _db.CreateContext(); + _tasks = new TaskRepository(_ctx); + _lists = new ListRepository(_ctx); + } + + 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; + } + + private async Task SeedTaskAsync(string listId, TaskStatus status = TaskStatus.Idle) + { + 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; + } + + private ExternalMcpService BuildSut() + { + var cfg = new WorkerConfig + { + SandboxRoot = Path.Combine(Path.GetTempPath(), $"cd_{Guid.NewGuid():N}"), + LogRoot = Path.Combine(Path.GetTempPath(), $"cdl_{Guid.NewGuid():N}"), + QueueBackstopIntervalMs = 50, + }; + var dbFactory = _db.CreateFactory(); + var hubCtx = new FakeHubContext(); + var broadcaster = new HubBroadcaster(hubCtx); + var git = new ClaudeDo.Data.Git.GitService(); + var wtManager = new WorktreeManager(git, dbFactory, cfg, NullLogger.Instance); + var fake = new FakeClaudeProcess(); + var argsBuilder = new ClaudeArgsBuilder(); + var state = TaskStateServiceBuilder.Build(dbFactory).State; + var runner = new TaskRunner(fake, dbFactory, broadcaster, wtManager, argsBuilder, cfg, + NullLogger.Instance, state, new TaskRunTokenRegistry()); + var waker = new ClaudeDo.Worker.Queue.QueueWaker(); + var picker = new ClaudeDo.Worker.Queue.QueuePicker(dbFactory); + var overrideSlot = new OverrideSlotService(dbFactory, runner, NullLogger.Instance); + var queue = new QueueService(dbFactory, runner, cfg, NullLogger.Instance, waker, picker, overrideSlot, state); + var maintenance = new WorktreeMaintenanceService(dbFactory, git, NullLogger.Instance); + var merge = new TaskMergeService(dbFactory, git, broadcaster, NullLogger.Instance); + return new ExternalMcpService( + _tasks, _lists, queue, broadcaster, + state, + git, dbFactory, maintenance, merge); + } + + [Fact] + public async Task AddSubtask_appends_row_with_next_order() + { + var listId = await SeedListAsync(); + var task = await SeedTaskAsync(listId, TaskStatus.Idle); + var sut = BuildSut(); + + await sut.AddSubtask(task.Id, "First step", null, CancellationToken.None); + await sut.AddSubtask(task.Id, "Second step", null, CancellationToken.None); + + await using var verifyCtx = _db.CreateContext(); + var subtasks = await new SubtaskRepository(verifyCtx).GetByTaskIdAsync(task.Id); + + Assert.Equal(2, subtasks.Count); + Assert.Equal("First step", subtasks[0].Title); + Assert.Equal("Second step", subtasks[1].Title); + Assert.Equal(0, subtasks[0].OrderNum); + Assert.Equal(1, subtasks[1].OrderNum); + Assert.False(subtasks[0].Completed); + Assert.False(subtasks[1].Completed); + } + + [Fact] + public async Task AddSubtask_refuses_running_task() + { + var listId = await SeedListAsync(); + var task = await SeedTaskAsync(listId, TaskStatus.Running); + var sut = BuildSut(); + + await Assert.ThrowsAsync( + () => sut.AddSubtask(task.Id, "Should fail", null, CancellationToken.None)); + } +}