using System.ComponentModel; using ModelContextProtocol.Server; namespace ClaudeDo.Worker.External; public sealed record BatchAddTaskInput(string Title, string? Description = null, string? Model = null); public sealed record BatchSetMyDayInput(string TaskId, bool IsMyDay, int? SortOrder = null); public sealed record BatchGetTaskResult(string Id, bool Found, TaskDto? Task, string? Error); public sealed record BatchAddTaskResult(int Index, string Title, bool Ok, TaskDto? Task, string? Error); public sealed record BatchTaskResult(string TaskId, bool Ok, string? Error); public sealed record BatchCancelResult(string TaskId, bool Ok, bool Cancelled, string? Error); public sealed record BatchCleanupResult(string TaskId, bool Ok, bool Removed, bool BranchDeleted, string? Error); /// /// Batch variants of the single-entity tools on . /// Every tool is best-effort: each item is attempted independently and reported in a /// per-item result array (a failing item never aborts the rest). Operations run /// sequentially — the underlying repositories share one scoped DbContext, which is not /// thread-safe. Merge/review stay single-task (conflicts need the interactive resolver). /// [McpServerToolType] public sealed class BatchMcpTools { private const int MaxBatchSize = 100; private readonly ExternalMcpService _svc; public BatchMcpTools(ExternalMcpService svc) => _svc = svc; [McpServerTool, Description( "Fetch a snapshot of many tasks in one call (overview / polling a fan-out). " + "Returns one result per id: { id, found, task, error }. A missing id is found=false " + "(not an error); error is only set for an unexpected failure. Max 100 ids.")] public async Task> BatchGetTasks( string[] taskIds, CancellationToken cancellationToken) { EnsureWithinCap(taskIds, nameof(taskIds)); var results = new List(taskIds.Length); foreach (var id in taskIds) { try { var task = await _svc.GetTask(id, cancellationToken); results.Add(new BatchGetTaskResult(id, true, task, null)); } catch (OperationCanceledException) { throw; } catch (InvalidOperationException) { results.Add(new BatchGetTaskResult(id, false, null, null)); } catch (Exception ex) { results.Add(new BatchGetTaskResult(id, false, null, ex.Message)); } } return results; } [McpServerTool, Description( "Create many tasks in one list at once. Each item: { title, description?, model? } " + "(model: haiku|sonnet|opus, blank = inherit list/global default). " + "queueImmediately enqueues every created task. " + "Returns one result per item: { index, title, ok, task, error }. Max 100 items.")] public async Task> BatchAddTasks( string listId, BatchAddTaskInput[] tasks, string? createdBy = null, bool queueImmediately = false, CancellationToken cancellationToken = default) { EnsureWithinCap(tasks, nameof(tasks)); var results = new List(tasks.Length); for (var i = 0; i < tasks.Length; i++) { var item = tasks[i]; try { var created = await _svc.AddTask( listId, item.Title, item.Description, createdBy, queueImmediately, item.Model, cancellationToken); results.Add(new BatchAddTaskResult(i, item.Title, true, created, null)); } catch (OperationCanceledException) { throw; } catch (Exception ex) { results.Add(new BatchAddTaskResult(i, item.Title, false, null, ex.Message)); } } return results; } [McpServerTool, Description( "Set the status of many tasks at once. status is 'Idle', 'Queued', 'Cancelled' or 'Done' only — " + "same rule as update_task_status ('Done' is refused per-item for a task with an active worktree). " + "Returns one result per id: { taskId, ok, error }. Max 100 ids.")] public async Task> BatchUpdateTaskStatus( string[] taskIds, string status, CancellationToken cancellationToken) { EnsureWithinCap(taskIds, nameof(taskIds)); return await RunPerTaskAsync(taskIds, (id, ct) => _svc.UpdateTaskStatus(id, status, ct), cancellationToken); } [McpServerTool, Description( "Cancel many running tasks at once. Returns one result per id: " + "{ taskId, ok, cancelled, error }. cancelled=false means the task was not running. " + "Max 100 ids.")] public async Task> BatchCancelTasks( string[] taskIds, CancellationToken cancellationToken) { EnsureWithinCap(taskIds, nameof(taskIds)); var results = new List(taskIds.Length); foreach (var id in taskIds) { try { var r = await _svc.CancelTask(id, cancellationToken); results.Add(new BatchCancelResult(id, true, r.Cancelled, null)); } catch (OperationCanceledException) { throw; } catch (Exception ex) { results.Add(new BatchCancelResult(id, false, false, ex.Message)); } } return results; } [McpServerTool, Description( "Delete many tasks at once. A Running task is refused (cancel it first) and reported " + "as ok=false with its error. Returns one result per id: { taskId, ok, error }. Max 100 ids.")] public async Task> BatchDeleteTasks( string[] taskIds, CancellationToken cancellationToken) { EnsureWithinCap(taskIds, nameof(taskIds)); return await RunPerTaskAsync(taskIds, (id, ct) => _svc.DeleteTask(id, ct), cancellationToken); } [McpServerTool, Description( "Daily prep: set/clear MyDay for many tasks at once. Each item: { taskId, isMyDay, sortOrder? }. " + "Still cap-guarded — items that would exceed DailyPrepMaxTasks open MyDay tasks fail individually " + "(ok=false) without blocking the rest. Returns one result per item: { taskId, ok, error }. Max 100 items.")] public async Task> BatchSetMyDay( BatchSetMyDayInput[] items, CancellationToken cancellationToken) { EnsureWithinCap(items, nameof(items)); var results = new List(items.Length); foreach (var item in items) { try { await _svc.SetMyDay(item.TaskId, item.IsMyDay, item.SortOrder, cancellationToken); results.Add(new BatchTaskResult(item.TaskId, true, null)); } catch (OperationCanceledException) { throw; } catch (Exception ex) { results.Add(new BatchTaskResult(item.TaskId, false, ex.Message)); } } return results; } [McpServerTool, Description( "Remove the worktrees of many tasks at once (directory + git branch). " + "force=false refuses a dirty or Running worktree (reported ok=false); force=true removes " + "even a dirty worktree (uncommitted changes lost), still refusing Running tasks. " + "Returns one result per id: { taskId, ok, removed, branchDeleted, error }. Max 100 ids.")] public async Task> BatchCleanupTaskWorktrees( string[] taskIds, bool force = false, CancellationToken cancellationToken = default) { EnsureWithinCap(taskIds, nameof(taskIds)); var results = new List(taskIds.Length); foreach (var id in taskIds) { try { var r = await _svc.CleanupTaskWorktree(id, force, cancellationToken); results.Add(new BatchCleanupResult(id, true, r.Removed, r.BranchDeleted, null)); } catch (OperationCanceledException) { throw; } catch (Exception ex) { results.Add(new BatchCleanupResult(id, false, false, false, ex.Message)); } } return results; } private static async Task> RunPerTaskAsync( string[] taskIds, Func op, CancellationToken cancellationToken) { var results = new List(taskIds.Length); foreach (var id in taskIds) { try { await op(id, cancellationToken); results.Add(new BatchTaskResult(id, true, null)); } catch (OperationCanceledException) { throw; } catch (Exception ex) { results.Add(new BatchTaskResult(id, false, ex.Message)); } } return results; } private static void EnsureWithinCap(IReadOnlyCollection? items, string name) { if (items is null || items.Count == 0) throw new InvalidOperationException($"{name} is required and must contain at least one item."); if (items.Count > MaxBatchSize) throw new InvalidOperationException( $"Batch too large: {items.Count} items (max {MaxBatchSize}). Split into smaller batches."); } }