Files
ClaudeDo/src/ClaudeDo.Worker/External/ListHandlerMcpTools.cs
T
mika kuns 451c01848c fix(worker): start_list_handler ehrlich machen, wenn keine App verbunden ist
ConnectedClientTracker zaehlt verbundene UI-Clients (WorkerHub.OnConnectedAsync/
OnDisconnectedAsync). StartListHandler prueft ihn vor dem Broadcast und liefert
bei 0 verbundenen Clients Requested=false plus Reason statt unbedingtem true.
Tool-Beschreibung und external-mcp.md an das reale Verhalten angepasst.
2026-09-10 13:33:30 +02:00

84 lines
3.9 KiB
C#

using System.ComponentModel;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Hub;
using ModelContextProtocol.Server;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Worker.External;
public sealed record StartListHandlerResult(bool Requested, string ListId, string ListName, int TaskCount, IReadOnlyList<int> TaskNumbers, string? Reason = null);
// The "Let Claude handle it" button, reachable from outside the UI. The worker only broadcasts;
// the App owns the ConPTY tile and creates the handler task exactly as the button does
// (MissionControlViewModel.OpenMergeHelperConPtySessionAsync), so there is one code path for
// starting a run, not two. Without a connected App the broadcast has nobody to receive it, so
// ConnectedClientTracker is checked first and Requested comes back false with a Reason instead of
// an unconditional true.
[McpServerToolType]
public sealed class ListHandlerMcpTools
{
private readonly ListRepository _lists;
private readonly TaskRepository _tasks;
private readonly HubBroadcaster _broadcaster;
private readonly ConnectedClientTracker _clients;
public ListHandlerMcpTools(ListRepository lists, TaskRepository tasks, HubBroadcaster broadcaster, ConnectedClientTracker clients)
{
_lists = lists;
_tasks = tasks;
_broadcaster = broadcaster;
_clients = clients;
}
[McpServerTool, Description(
"Start a list-handler run (\"Let Claude handle it\") for a list, exactly like the button in " +
"Mission Control: the connected ClaudeDo App creates the handler task and opens the triage " +
"ConPTY tile for the given tasks. taskIds defaults to every Idle, non-manual task of the list. " +
"Requires the App to be running and connected to this worker -- if it isn't, Requested comes " +
"back false with a reason and nothing is started. taskIds accept task numbers (#123 or bare 123).")]
public async Task<StartListHandlerResult> StartListHandler(
[Description("The list to run the handler on.")] string listId,
[Description("Tasks to hand to the run, in the order to triage them. Omit for all Idle, non-manual tasks of the list.")] IReadOnlyList<string>? taskIds = null,
CancellationToken cancellationToken = default)
{
var list = await _lists.GetByIdAsync(listId, cancellationToken)
?? throw new InvalidOperationException($"List {listId} not found.");
var listTasks = await _tasks.GetByListIdAsync(listId, cancellationToken);
List<TaskEntity> chosen;
if (taskIds is { Count: > 0 })
{
var resolved = await TaskIdResolver.ResolveManyAsync(_tasks, taskIds, cancellationToken);
chosen = new List<TaskEntity>();
foreach (var id in resolved)
{
var t = listTasks.FirstOrDefault(x => x.Id == id)
?? throw new InvalidOperationException($"Task {id} not found in list {list.Name}.");
chosen.Add(t);
}
}
else
{
chosen = listTasks
.Where(t => t.Status == TaskStatus.Idle && !t.IsManual && t.ParentTaskId is null)
.OrderBy(t => t.SortOrder)
.ToList();
}
if (chosen.Count == 0)
throw new InvalidOperationException($"No tasks to hand to the list handler for {list.Name}.");
var numbers = chosen.Select(t => t.Number).ToList();
if (!_clients.AnyConnected)
{
return new StartListHandlerResult(false, listId, list.Name, chosen.Count, numbers,
"No ClaudeDo App is connected to this worker, so the request could not be delivered. Open the app and retry.");
}
var ids = chosen.Select(t => t.Id).ToList();
await _broadcaster.ListHandlerStartRequested(listId, ids);
return new StartListHandlerResult(true, listId, list.Name, ids.Count, numbers);
}
}