list_tasks on a list of ~100 verbosely-described tasks could return 390k+ characters in one call, blowing past the caller's token limit. Both tools now default to lean TaskRefDto references (no Description/Result) and take an includeDescription flag to opt back into the full TaskDto payload — same flag-alongside-nullable-payload idiom already used by BatchGetTaskResult/TaskConfigResult. get_task is unchanged.
241 lines
10 KiB
C#
241 lines
10 KiB
C#
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);
|
|
|
|
// 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). Both are null when found=false.
|
|
public sealed record BatchGetTaskResult(string Id, bool Found, TaskRefDto? Task, TaskDto? TaskFull, string? Error);
|
|
public sealed record BatchAddTaskResult(int Index, string Title, bool Ok, TaskRefDto? 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);
|
|
|
|
/// <summary>
|
|
/// Batch variants of the single-entity tools on <see cref="ExternalMcpService"/>.
|
|
/// 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).
|
|
/// </summary>
|
|
[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, taskFull, error }. " +
|
|
"includeDescription=false (default): found tasks come back in `task` (lean reference, no " +
|
|
"Description/Result). includeDescription=true: found tasks come back in `taskFull` (incl. " +
|
|
"Description/Result) instead. A missing id is found=false (not an error; task and taskFull both null); " +
|
|
"error is only set for an unexpected failure. Max 100 ids.")]
|
|
public async Task<IReadOnlyList<BatchGetTaskResult>> BatchGetTasks(
|
|
string[] taskIds, bool includeDescription = false, CancellationToken cancellationToken = default)
|
|
{
|
|
EnsureWithinCap(taskIds, nameof(taskIds));
|
|
|
|
var results = new List<BatchGetTaskResult>(taskIds.Length);
|
|
foreach (var id in taskIds)
|
|
{
|
|
try
|
|
{
|
|
if (includeDescription)
|
|
{
|
|
var task = await _svc.GetTask(id, cancellationToken);
|
|
results.Add(new BatchGetTaskResult(id, true, null, task, 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));
|
|
}
|
|
}
|
|
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 }; task is a lean reference " +
|
|
"(id, listId, title, status, sortOrder, isMyDay), not the description you just sent. Max 100 items.")]
|
|
public async Task<IReadOnlyList<BatchAddTaskResult>> BatchAddTasks(
|
|
string listId,
|
|
BatchAddTaskInput[] tasks,
|
|
string? createdBy = null,
|
|
bool queueImmediately = false,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
EnsureWithinCap(tasks, nameof(tasks));
|
|
|
|
var results = new List<BatchAddTaskResult>(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<IReadOnlyList<BatchTaskResult>> 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<IReadOnlyList<BatchCancelResult>> BatchCancelTasks(
|
|
string[] taskIds, CancellationToken cancellationToken)
|
|
{
|
|
EnsureWithinCap(taskIds, nameof(taskIds));
|
|
|
|
var results = new List<BatchCancelResult>(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<IReadOnlyList<BatchTaskResult>> 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<IReadOnlyList<BatchTaskResult>> BatchSetMyDay(
|
|
BatchSetMyDayInput[] items, CancellationToken cancellationToken)
|
|
{
|
|
EnsureWithinCap(items, nameof(items));
|
|
|
|
var results = new List<BatchTaskResult>(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<IReadOnlyList<BatchCleanupResult>> BatchCleanupTaskWorktrees(
|
|
string[] taskIds, bool force = false, CancellationToken cancellationToken = default)
|
|
{
|
|
EnsureWithinCap(taskIds, nameof(taskIds));
|
|
|
|
var results = new List<BatchCleanupResult>(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<IReadOnlyList<BatchTaskResult>> RunPerTaskAsync(
|
|
string[] taskIds, Func<string, CancellationToken, Task> op, CancellationToken cancellationToken)
|
|
{
|
|
var results = new List<BatchTaskResult>(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<T>(IReadOnlyCollection<T>? 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.");
|
|
}
|
|
}
|