using System.ComponentModel; using System.Text.Json; using ClaudeDo.Worker.Git; using ClaudeDo.Worker.Lifecycle; using ModelContextProtocol; using ModelContextProtocol.Server; namespace ClaudeDo.Worker.External; public sealed record BatchAddTaskInput( string Title, [property: Description("Task description/instructions for the agent.")] string? Description = null, [property: Description("Model override: haiku|sonnet|opus. Blank inherits the list/global default.")] string? Model = null); public sealed record BatchSetMyDayInput( string TaskId, [property: Description("true to add the task to My Day, false to remove it.")] bool IsMyDay, [property: Description("Position within My Day; omit to append at the end.")] int? SortOrder = null); // task is populated when found and includeDescription=false (the default, lean reference); // taskFull is populated when found and includeDescription=true (full task incl. // Description/Result, possibly narrowed by `fields` and/or truncated). Both are null when // found=false. public sealed record BatchGetTaskResult(string Id, bool Found, TaskRefDto? Task, BatchTaskDetailDto? TaskFull, string? Error); // Batch-only counterpart to TaskDto (deliberately not TaskDto itself — get_task's own shape stays // untouched). Every field defaults to null; BatchGetTasks only populates the ones the caller asked // for via `fields` (or all of them when `fields` is omitted). Description/Result are cut to // descriptionMaxChars with the *Truncated/*FullLength pair telling the caller it happened and how // much text there really is. RoadblockText is the tail of Result after TaskRunner's roadblock // marker (see ComposeReviewResult in Runner/TaskRunner.cs) — the few bullet lines a roadblocked // run reported, without pulling in the rest of Result/Description to get them. public sealed record BatchTaskDetailDto( string Id, int Number, string? ListId = null, string? Title = null, string? Description = null, string? Status = null, string? Result = null, string? CreatedBy = null, DateTime? CreatedAt = null, DateTime? StartedAt = null, DateTime? FinishedAt = null, bool? IsMyDay = null, int? SortOrder = null, int? RoadblockCount = null, string? RoadblockText = null, bool DescriptionTruncated = false, int? DescriptionFullLength = null, bool ResultTruncated = false, int? ResultFullLength = null); public sealed record BatchAddTaskResult( int Index, string Title, bool Ok, TaskRefDto? Task, IReadOnlyList? PossibleDuplicates, string? Error); // BaseDirty mirrors TaskRefDto.BaseDirty: populated only for BatchUpdateTaskStatus items that // just transitioned to Queued against a list whose working dir has uncommitted changes. public sealed record BatchTaskResult(string TaskId, bool Ok, string? Error, DirtyBaseWarning? BaseDirty = null, int? Number = null); public sealed record BatchCancelResult(string TaskId, bool Ok, bool Cancelled, string? Error, int? Number = null); public sealed record BatchCleanupResult(string TaskId, bool Ok, bool Removed, bool BranchDeleted, string? Error, int? Number = null); /// /// 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; // Roughly 6k tokens of headroom under a typical 25k-token tool-result limit — the incident // this guards against was a single 51k-char line from 8 tasks' full Description/Result. private const int MaxResponseChars = 25_000; private const string RoadblockMarker = "Roadblocks reported during the run:"; private static readonly string[] KnownDetailFields = { "listId", "title", "description", "status", "result", "createdBy", "createdAt", "startedAt", "finishedAt", "isMyDay", "sortOrder", "roadblockCount", "roadblockText", }; private readonly ExternalMcpService _svc; public BatchMcpTools(ExternalMcpService svc) => _svc = svc; [McpServerTool, Description( "Fetch a snapshot of many tasks in one call — use for an overview or polling a fan-out instead of " + "calling get_task per id. A missing id comes back as found=false, not an error; error is only set " + "for an unexpected failure. A Failed task's task/taskFull carries failureReason (see get_task). " + "When includeDescription=true, Description/Result are cut to " + "descriptionMaxChars (default 1500 — was unlimited); check *Truncated/*FullLength before assuming " + "you got the whole text. Use `fields` to fetch only what you need (e.g. just roadblockText) instead " + "of raising the cap. The whole response is still capped — if it's too big even so, lower " + "descriptionMaxChars, narrow `fields`, or split taskIds into a smaller batch." + McpToolDocs.MaxBatch + McpToolDocs.BatchProgressHint)] public async Task> BatchGetTasks( string[] taskIds, [Description("If true, return the full task (incl. Description/Result) in `taskFull`; if false " + "(default), return a lean reference in `task`.")] bool includeDescription = false, [Description("Max chars kept for Description and for Result (each independently) when " + "includeDescription=true; longer text is cut and flagged via *Truncated/*FullLength. Ignored " + "when includeDescription=false.")] int descriptionMaxChars = 1500, [Description("Restrict `taskFull` to these field names (e.g. ['title','status','roadblockText']) " + "instead of returning all of them; unknown names are rejected. Ignored when " + "includeDescription=false.")] string[]? fields = null, CancellationToken cancellationToken = default, IProgress? progress = null) { EnsureWithinCap(taskIds, nameof(taskIds)); if (descriptionMaxChars < 0) throw new InvalidOperationException($"{nameof(descriptionMaxChars)} must be >= 0."); ValidateFields(fields); var results = new List(taskIds.Length); for (var i = 0; i < taskIds.Length; i++) { var id = taskIds[i]; try { if (includeDescription) { var task = await _svc.GetTask(id, cancellationToken); results.Add(new BatchGetTaskResult(id, true, null, BuildDetail(task, fields, descriptionMaxChars), null)); } else { var taskRef = await _svc.GetTaskRefAsync(id, cancellationToken); results.Add(new BatchGetTaskResult(id, true, taskRef, null, null)); } } catch (OperationCanceledException) { throw; } catch (InvalidOperationException) { results.Add(new BatchGetTaskResult(id, false, null, null, null)); } catch (Exception ex) { results.Add(new BatchGetTaskResult(id, false, null, null, ex.Message)); } ProgressReporter.ReportItem(progress, "Fetching tasks", i + 1, taskIds.Length); } var responseLength = JsonSerializer.Serialize(results).Length; if (responseLength > MaxResponseChars) throw new InvalidOperationException( $"Response too large: {responseLength} chars (max {MaxResponseChars}). Lower " + $"{nameof(descriptionMaxChars)}, narrow `fields`, or split {nameof(taskIds)} into a smaller batch."); return results; } private static void ValidateFields(string[]? fields) { if (fields is null) return; var unknown = fields.Where(f => !KnownDetailFields.Contains(f, StringComparer.OrdinalIgnoreCase)).ToArray(); if (unknown.Length > 0) throw new InvalidOperationException( $"Unknown field(s) in `fields`: {string.Join(", ", unknown)}. Known fields: " + string.Join(", ", KnownDetailFields)); } private static BatchTaskDetailDto BuildDetail(TaskDto t, string[]? fields, int descriptionMaxChars) { bool Want(string name) => fields is null || fields.Contains(name, StringComparer.OrdinalIgnoreCase); var (description, descTruncated, descFullLength) = Want("description") ? Truncate(t.Description, descriptionMaxChars) : (null, false, null); var (result, resultTruncated, resultFullLength) = Want("result") ? Truncate(t.Result, descriptionMaxChars) : (null, false, null); return new BatchTaskDetailDto( Id: t.Id, Number: t.Number, ListId: Want("listId") ? t.ListId : null, Title: Want("title") ? t.Title : null, Description: description, Status: Want("status") ? t.Status : null, Result: result, CreatedBy: Want("createdBy") ? t.CreatedBy : null, CreatedAt: Want("createdAt") ? t.CreatedAt : null, StartedAt: Want("startedAt") ? t.StartedAt : null, FinishedAt: Want("finishedAt") ? t.FinishedAt : null, IsMyDay: Want("isMyDay") ? t.IsMyDay : null, SortOrder: Want("sortOrder") ? t.SortOrder : null, RoadblockCount: Want("roadblockCount") ? t.RoadblockCount : null, RoadblockText: Want("roadblockText") ? ExtractRoadblockText(t.Result) : null, DescriptionTruncated: descTruncated, DescriptionFullLength: descFullLength, ResultTruncated: resultTruncated, ResultFullLength: resultFullLength); } private static (string? Text, bool Truncated, int? FullLength) Truncate(string? text, int maxChars) { if (text is null || text.Length <= maxChars) return (text, false, null); return (text[..maxChars], true, text.Length); } private static string? ExtractRoadblockText(string? result) { if (string.IsNullOrEmpty(result)) return null; var idx = result.IndexOf(RoadblockMarker, StringComparison.Ordinal); return idx < 0 ? null : result[(idx + RoadblockMarker.Length)..].Trim(); } [McpServerTool, Description( "Create many tasks in one list at once — use instead of repeated add_task calls when seeding a list. " + "Every item is still created even if it looks like a duplicate; possibleDuplicates is a non-blocking " + "heads-up (up to 3 similar open tasks in the list) worth mentioning to the caller, not an error." + McpToolDocs.LeanTaskRef + McpToolDocs.MaxBatch + McpToolDocs.BatchProgressHint)] public async Task> BatchAddTasks( string listId, BatchAddTaskInput[] tasks, string? createdBy = null, [Description("If true, enqueue every created task immediately instead of leaving it Idle.")] bool queueImmediately = false, CancellationToken cancellationToken = default, IProgress? progress = null) { 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: cancellationToken); results.Add(new BatchAddTaskResult(i, item.Title, true, created.Task, created.PossibleDuplicates, null)); } catch (OperationCanceledException) { throw; } catch (Exception ex) { results.Add(new BatchAddTaskResult(i, item.Title, false, null, null, ex.Message)); } ProgressReporter.ReportItem(progress, "Adding tasks", i + 1, tasks.Length); } return results; } [McpServerTool, Description( "Set the status of many tasks at once — use for bulk queue/cancel/done actions instead of calling " + "update_task_status per task. 'Done' is refused per-item for a task with an active worktree. " + "baseDirty on a 'Queued' item means that task's list has uncommitted changes in its working dir right " + "now — a new worktree forks from the last commit and won't include them; non-blocking, but worth " + "checking before assuming a fresh worktree starts from what's on disk." + McpToolDocs.MaxBatch + McpToolDocs.BatchProgressHint)] public async Task> BatchUpdateTaskStatus( string[] taskIds, [Description("One of 'Idle', 'Queued', 'Cancelled', or 'Done'.")] string status, CancellationToken cancellationToken, IProgress? progress = null) { EnsureWithinCap(taskIds, nameof(taskIds)); var results = new List(taskIds.Length); for (var i = 0; i < taskIds.Length; i++) { var id = taskIds[i]; try { var task = await _svc.UpdateTaskStatus(id, status, cancellationToken); results.Add(new BatchTaskResult(id, true, null, task.BaseDirty, task.Number)); } catch (OperationCanceledException) { throw; } catch (Exception ex) { results.Add(new BatchTaskResult(id, false, ex.Message)); } ProgressReporter.ReportItem(progress, "Updating task status", i + 1, taskIds.Length); } return results; } [McpServerTool, Description( "Cancel many running tasks at once — use to bulk-stop tasks instead of calling cancel_task per id. " + "ok=true with cancelled=false just means the task wasn't running." + McpToolDocs.MaxBatch + McpToolDocs.BatchProgressHint)] public async Task> BatchCancelTasks( string[] taskIds, CancellationToken cancellationToken, IProgress? progress = null) { EnsureWithinCap(taskIds, nameof(taskIds)); var results = new List(taskIds.Length); for (var i = 0; i < taskIds.Length; i++) { var id = taskIds[i]; try { var r = await _svc.CancelTask(id, cancellationToken); results.Add(new BatchCancelResult(id, true, r.Cancelled, null, r.Number)); } catch (OperationCanceledException) { throw; } catch (Exception ex) { results.Add(new BatchCancelResult(id, false, false, ex.Message)); } ProgressReporter.ReportItem(progress, "Cancelling tasks", i + 1, taskIds.Length); } return results; } [McpServerTool, Description( "Delete many tasks at once — use for bulk cleanup instead of calling delete_task per id." + McpToolDocs.NotWhileRunning + McpToolDocs.MaxBatch + McpToolDocs.BatchProgressHint)] public async Task> BatchDeleteTasks( string[] taskIds, CancellationToken cancellationToken, IProgress? progress = null) { EnsureWithinCap(taskIds, nameof(taskIds)); return await RunPerTaskAsync(taskIds, async (id, ct) => (await _svc.DeleteTask(id, ct)).Number, cancellationToken, progress, "Deleting tasks"); } [McpServerTool, Description( "Set or clear MyDay (daily prep) for many tasks at once — use instead of calling set_my_day per task. " + "Still cap-guarded: items that would exceed DailyPrepMaxTasks open MyDay tasks fail individually " + "(ok=false) without blocking the rest." + McpToolDocs.MaxBatch + McpToolDocs.BatchProgressHint)] public async Task> BatchSetMyDay( BatchSetMyDayInput[] items, CancellationToken cancellationToken, IProgress? progress = null) { EnsureWithinCap(items, nameof(items)); var results = new List(items.Length); for (var i = 0; i < items.Length; i++) { var item = items[i]; try { var task = await _svc.SetMyDay(item.TaskId, item.IsMyDay, item.SortOrder, cancellationToken); results.Add(new BatchTaskResult(item.TaskId, true, null, Number: task.Number)); } catch (OperationCanceledException) { throw; } catch (Exception ex) { results.Add(new BatchTaskResult(item.TaskId, false, ex.Message)); } ProgressReporter.ReportItem(progress, "Setting My Day", i + 1, items.Length); } return results; } [McpServerTool, Description( "Remove the worktrees (directory + git branch) of many tasks at once — use for bulk cleanup instead " + "of calling cleanup_task_worktree per id." + McpToolDocs.NotWhileRunning + McpToolDocs.MaxBatch + McpToolDocs.BatchProgressHint)] public async Task> BatchCleanupTaskWorktrees( string[] taskIds, [Description("If true, also remove a dirty worktree, losing uncommitted changes; a Running task " + "is still refused either way.")] bool force = false, CancellationToken cancellationToken = default, IProgress? progress = null) { EnsureWithinCap(taskIds, nameof(taskIds)); var results = new List(taskIds.Length); for (var i = 0; i < taskIds.Length; i++) { var id = taskIds[i]; try { var r = await _svc.CleanupTaskWorktree(id, force, cancellationToken); results.Add(new BatchCleanupResult(id, true, r.Removed, r.BranchDeleted, null, r.Number)); } catch (OperationCanceledException) { throw; } catch (Exception ex) { results.Add(new BatchCleanupResult(id, false, false, false, ex.Message)); } ProgressReporter.ReportItem(progress, "Cleaning up worktrees", i + 1, taskIds.Length); } return results; } private static async Task> RunPerTaskAsync( string[] taskIds, Func> op, CancellationToken cancellationToken, IProgress? progress, string progressAction) { var results = new List(taskIds.Length); for (var i = 0; i < taskIds.Length; i++) { var id = taskIds[i]; try { var number = await op(id, cancellationToken); results.Add(new BatchTaskResult(id, true, null, Number: number)); } catch (OperationCanceledException) { throw; } catch (Exception ex) { results.Add(new BatchTaskResult(id, false, ex.Message)); } ProgressReporter.ReportItem(progress, progressAction, i + 1, taskIds.Length); } 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."); } }