using System.ComponentModel; using ClaudeDo.Data.Models; using ClaudeDo.Data.Repositories; using ClaudeDo.Worker.Hub; using ClaudeDo.Worker.Services; 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 TagDto(long Id, string Name); 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 TagRepository _tags; public ExternalMcpService( TaskRepository tasks, ListRepository lists, QueueService queue, HubBroadcaster broadcaster, TagRepository tags) { _tasks = tasks; _lists = lists; _queue = queue; _broadcaster = broadcaster; _tags = tags; } [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. Optional tags are attached on creation; missing tag names auto-create.")] public async Task AddTask( string listId, string title, string? description, string createdBy, bool queueImmediately, IReadOnlyList? tags, 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 = queueImmediately ? TaskStatus.Queued : TaskStatus.Manual, CreatedAt = DateTime.UtcNow, CommitType = list.DefaultCommitType, CreatedBy = createdBy, }; await _tasks.AddAsync(entity, cancellationToken); if (tags is not null && tags.Count > 0) await _tasks.SetTagsAsync(entity.Id, tags, cancellationToken); if (queueImmediately) _queue.WakeQueue(); await _broadcaster.TaskUpdated(entity.Id); return ToDto(entity); } [McpServerTool, Description("Update an existing task's title, description, commit type, and/or tags. Pass null to leave a field unchanged. Tags are replaced as a full set when non-null. Refuses if the task is currently Running.")] public async Task UpdateTask( string taskId, string? title, string? description, string? commitType, IReadOnlyList? tags, 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); if (tags is not null) await _tasks.SetTagsAsync(taskId, tags, cancellationToken); var reload = (await _tasks.GetByIdAsync(taskId, cancellationToken))!; await _broadcaster.TaskUpdated(taskId); return ToDto(reload); } [McpServerTool, Description("Update a task's status. Only 'Manual' 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.Manual: await _tasks.ResetToManualAsync(taskId, cancellationToken); break; case TaskStatus.Queued: if (task.Status is TaskStatus.Running) throw new InvalidOperationException("Cannot enqueue a running task."); task.Status = TaskStatus.Queued; await _tasks.UpdateAsync(task, cancellationToken); _queue.WakeQueue(); break; default: throw new InvalidOperationException( $"Status '{target}' is not settable externally. Use RunTaskNow or CancelTask."); } var reload = (await _tasks.GetByIdAsync(taskId, cancellationToken))!; await _broadcaster.TaskUpdated(taskId); 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); } [McpServerTool, Description("Replace the full tag set on an existing task. Missing tag names auto-create. Refuses if the task is Running.")] public async Task SetTaskTags( string taskId, IReadOnlyList tags, 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 retag a running task. Cancel it first."); await _tasks.SetTagsAsync(taskId, tags, cancellationToken); var reload = (await _tasks.GetByIdAsync(taskId, cancellationToken))!; await _broadcaster.TaskUpdated(taskId); return ToDto(reload); } [McpServerTool, Description("List all known tags. Useful for discovering existing tag names (including 'agent' which marks tasks for auto-execution) before tagging.")] public async Task> ListTags(CancellationToken cancellationToken) { var tags = await _tags.GetAllAsync(cancellationToken); return tags.Select(t => new TagDto(t.Id, t.Name)).ToList(); } 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); }