using System.ComponentModel; using ClaudeDo.Data.Models; using ClaudeDo.Data.Repositories; using ClaudeDo.Worker.Hub; using ClaudeDo.Worker.Queue; using ClaudeDo.Worker.State; using ModelContextProtocol.Server; using TaskStatus = ClaudeDo.Data.Models.TaskStatus; namespace ClaudeDo.Worker.External; public sealed record TaskListDto(string Id, string Name, string? WorkingDir); public sealed record TaskDto( string Id, string ListId, string Title, string? Description, string Status, string? Result, string? CreatedBy, DateTime CreatedAt, DateTime? StartedAt, DateTime? FinishedAt); [McpServerToolType] public sealed class ExternalMcpService { private readonly TaskRepository _tasks; private readonly ListRepository _lists; private readonly QueueService _queue; private readonly HubBroadcaster _broadcaster; private readonly ITaskStateService _state; public ExternalMcpService( TaskRepository tasks, ListRepository lists, QueueService queue, HubBroadcaster broadcaster, ITaskStateService state) { _tasks = tasks; _lists = lists; _queue = queue; _broadcaster = broadcaster; _state = state; } [McpServerTool, Description("List all task lists available in ClaudeDo.")] public async Task> ListTaskLists(CancellationToken cancellationToken) { var lists = await _lists.GetAllAsync(cancellationToken); return lists.Select(l => new TaskListDto(l.Id, l.Name, l.WorkingDir)).ToList(); } [McpServerTool, Description("List tasks in a given list. Optionally filter by creator (CreatedBy) and/or status.")] public async Task> ListTasks( string listId, string? createdBy, string? status, CancellationToken cancellationToken) { TaskStatus? statusFilter = null; if (!string.IsNullOrWhiteSpace(status)) { if (!Enum.TryParse(status, ignoreCase: true, out var parsed)) throw new InvalidOperationException($"Unknown status '{status}'."); statusFilter = parsed; } var tasks = await _tasks.GetByListIdAsync(listId, cancellationToken); IEnumerable query = tasks; if (createdBy is not null) query = query.Where(t => t.CreatedBy == createdBy); if (statusFilter is not null) query = query.Where(t => t.Status == statusFilter); return query.Select(ToDto).ToList(); } [McpServerTool, Description("Get a single task by id, including its current status and result.")] public async Task GetTask(string taskId, CancellationToken cancellationToken) { var task = await _tasks.GetByIdAsync(taskId, cancellationToken) ?? throw new InvalidOperationException($"Task {taskId} not found."); return ToDto(task); } [McpServerTool, Description("Create a new task in the given list. Set queueImmediately=true to enqueue it for agent execution.")] public async Task AddTask( string listId, string title, string? description, string createdBy, bool queueImmediately, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(listId)) throw new InvalidOperationException("listId is required."); if (string.IsNullOrWhiteSpace(title)) throw new InvalidOperationException("title is required."); if (string.IsNullOrWhiteSpace(createdBy)) throw new InvalidOperationException("createdBy is required."); var list = await _lists.GetByIdAsync(listId, cancellationToken) ?? throw new InvalidOperationException($"List {listId} not found."); var entity = new TaskEntity { Id = Guid.NewGuid().ToString(), ListId = listId, Title = title, Description = description, Status = TaskStatus.Idle, CreatedAt = DateTime.UtcNow, CommitType = list.DefaultCommitType, CreatedBy = createdBy, }; await _tasks.AddAsync(entity, cancellationToken); if (queueImmediately) { // Routes through TaskStateService so the queue is woken automatically. var enqueue = await _state.EnqueueAsync(entity.Id, cancellationToken); if (!enqueue.Ok) throw new InvalidOperationException(enqueue.Reason ?? "Cannot enqueue task."); entity.Status = TaskStatus.Queued; } await _broadcaster.TaskUpdated(entity.Id); return ToDto(entity); } [McpServerTool, Description("Update an existing task's title, description, and/or commit type. Pass null to leave a field unchanged. Refuses if the task is currently Running.")] public async Task UpdateTask( string taskId, string? title, string? description, string? commitType, CancellationToken cancellationToken) { var task = await _tasks.GetByIdAsync(taskId, cancellationToken) ?? throw new InvalidOperationException($"Task {taskId} not found."); if (task.Status == TaskStatus.Running) throw new InvalidOperationException("Cannot update a running task. Cancel it first."); if (title is not null) task.Title = title; if (description is not null) task.Description = description; if (commitType is not null) task.CommitType = commitType; await _tasks.UpdateAsync(task, cancellationToken); var reload = (await _tasks.GetByIdAsync(taskId, cancellationToken))!; await _broadcaster.TaskUpdated(taskId); return ToDto(reload); } [McpServerTool, Description("Update a task's status. Only 'Idle' and 'Queued' are permitted — use RunTaskNow or CancelTask for execution control.")] public async Task UpdateTaskStatus( string taskId, string status, CancellationToken cancellationToken) { if (!Enum.TryParse(status, ignoreCase: true, out var target)) throw new InvalidOperationException($"Unknown status '{status}'."); var task = await _tasks.GetByIdAsync(taskId, cancellationToken) ?? throw new InvalidOperationException($"Task {taskId} not found."); switch (target) { case TaskStatus.Idle: await _tasks.ResetToManualAsync(taskId, cancellationToken); await _broadcaster.TaskUpdated(taskId); break; case TaskStatus.Queued: var enqueueResult = await _state.EnqueueAsync(taskId, cancellationToken); if (!enqueueResult.Ok) throw new InvalidOperationException(enqueueResult.Reason ?? "Cannot enqueue task."); break; default: throw new InvalidOperationException( $"Status '{target}' is not settable externally. Use RunTaskNow or CancelTask."); } var reload = (await _tasks.GetByIdAsync(taskId, cancellationToken))!; return ToDto(reload); } [McpServerTool, Description("Immediately run a task in the override execution slot (bypasses the agent queue).")] public async Task RunTaskNow(string taskId, CancellationToken cancellationToken) { try { await _queue.RunNow(taskId); } catch (InvalidOperationException) { throw new InvalidOperationException("Override slot busy. Try again later."); } catch (KeyNotFoundException) { throw new InvalidOperationException($"Task {taskId} not found."); } await _broadcaster.TaskUpdated(taskId); } [McpServerTool, Description("Cancel a running task. Returns true if the task was running and cancellation was requested.")] public async Task CancelTask(string taskId, CancellationToken cancellationToken) { var cancelled = _queue.CancelTask(taskId); if (cancelled) await _broadcaster.TaskUpdated(taskId); return cancelled; } [McpServerTool, Description("Delete a task. Refuses if the task is currently Running — cancel it first.")] public async Task DeleteTask(string taskId, CancellationToken cancellationToken) { var task = await _tasks.GetByIdAsync(taskId, cancellationToken) ?? throw new InvalidOperationException($"Task {taskId} not found."); if (task.Status == TaskStatus.Running) throw new InvalidOperationException("Cannot delete a running task. Cancel it first."); await _tasks.DeleteAsync(taskId, cancellationToken); await _broadcaster.TaskUpdated(taskId); } private static TaskDto ToDto(TaskEntity t) => new( t.Id, t.ListId, t.Title, t.Description, t.Status.ToString(), t.Result, t.CreatedBy, t.CreatedAt, t.StartedAt, t.FinishedAt); }