using System.ComponentModel; using ClaudeDo.Data.Models; using ClaudeDo.Data.Repositories; using ClaudeDo.Worker.Hub; using ClaudeDo.Worker.State; using ModelContextProtocol.Server; using TaskStatus = ClaudeDo.Data.Models.TaskStatus; namespace ClaudeDo.Worker.Planning; public sealed record ChildTaskDto(string TaskId, string Title, string? Description, string Status); public sealed record CreatedChildDto(string TaskId, string Status); [McpServerToolType] public sealed class PlanningMcpService { private readonly TaskRepository _tasks; private readonly PlanningMcpContextAccessor _contextAccessor; private readonly HubBroadcaster _broadcaster; private readonly ITaskStateService _state; private readonly PlanningChainCoordinator _chain; public PlanningMcpService( TaskRepository tasks, PlanningMcpContextAccessor contextAccessor, HubBroadcaster broadcaster, ITaskStateService state, PlanningChainCoordinator chain) { _tasks = tasks; _contextAccessor = contextAccessor; _broadcaster = broadcaster; _state = state; _chain = chain; } private Task BroadcastTaskUpdatedAsync(string taskId, CancellationToken ct) => _broadcaster.TaskUpdated(taskId); [McpServerTool, Description( "Create a new draft child task under the current planning session's parent task. " + "Set model to the cheapest model that can do this subtask well — 'haiku' for trivial/mechanical " + "work, 'sonnet' for normal coding (the default), 'opus' only for complex or cross-cutting work. " + "Leave model null to inherit the list/global default. Set maxTurns only when this subtask is " + "visibly larger than the default turn budget; leave it null to inherit the list/global default.")] public async Task CreateChildTask( string title, string? description, string? commitType, string? model, int? maxTurns = null, CancellationToken cancellationToken = default) { var ctx = _contextAccessor.Current; var child = await _tasks.CreateChildAsync(ctx.ParentTaskId, title, description, commitType, createdBy: null, model: model, maxTurns: maxTurns, ct: cancellationToken); await BroadcastTaskUpdatedAsync(child.Id, cancellationToken); await BroadcastTaskUpdatedAsync(ctx.ParentTaskId, cancellationToken); return new CreatedChildDto(child.Id, child.Status.ToString()); } [McpServerTool, Description("List all child tasks under the current planning session's parent task.")] public async Task> ListChildTasks( CancellationToken cancellationToken) { var ctx = _contextAccessor.Current; var children = await _tasks.GetChildrenAsync(ctx.ParentTaskId, cancellationToken); return children .Select(c => new ChildTaskDto(c.Id, c.Title, c.Description, c.Status.ToString())) .ToList(); } private static readonly TaskStatus[] EditableStatuses = { TaskStatus.Idle, TaskStatus.Queued }; [McpServerTool, Description("Update a child task in the active planning session. Can change title, description, commit type, and status. Status must be one of: Idle, Queued.")] public async Task UpdateChildTask( string taskId, string? title, string? description, string? commitType, string? status, CancellationToken cancellationToken) { var ctx = _contextAccessor.Current; var parent = await _tasks.GetByIdAsync(ctx.ParentTaskId, cancellationToken) ?? throw new InvalidOperationException("Planning parent task not found."); if (parent.PlanningPhase != PlanningPhase.Active) throw new InvalidOperationException("Cannot modify tasks outside an active planning session."); var child = await _tasks.GetByIdAsync(taskId, cancellationToken) ?? throw new InvalidOperationException($"Task {taskId} not found."); if (child.ParentTaskId != ctx.ParentTaskId) throw new InvalidOperationException("Task is not a child of this planning session."); TaskStatus? newStatus = null; if (!string.IsNullOrEmpty(status)) { if (!Enum.TryParse(status, ignoreCase: true, out var parsed)) throw new InvalidOperationException($"Unknown status '{status}'."); if (!EditableStatuses.Contains(parsed)) throw new InvalidOperationException($"Status '{parsed}' cannot be set via MCP. Allowed: Idle, Queued."); newStatus = parsed; } await _tasks.UpdateChildAsync(taskId, title, description, commitType, newStatus, cancellationToken); var reload = (await _tasks.GetByIdAsync(taskId, cancellationToken))!; await BroadcastTaskUpdatedAsync(reload.Id, cancellationToken); await BroadcastTaskUpdatedAsync(ctx.ParentTaskId, cancellationToken); return new ChildTaskDto(reload.Id, reload.Title, reload.Description, reload.Status.ToString()); } [McpServerTool, Description("Delete a child task in the active planning session.")] public async Task DeleteChildTask( string taskId, CancellationToken cancellationToken) { var ctx = _contextAccessor.Current; var parent = await _tasks.GetByIdAsync(ctx.ParentTaskId, cancellationToken) ?? throw new InvalidOperationException("Planning parent task not found."); if (parent.PlanningPhase != PlanningPhase.Active) throw new InvalidOperationException("Cannot delete tasks outside an active planning session."); var child = await _tasks.GetByIdAsync(taskId, cancellationToken) ?? throw new InvalidOperationException($"Task {taskId} not found."); if (child.ParentTaskId != ctx.ParentTaskId) throw new InvalidOperationException("Task is not a child of this planning session."); await _tasks.DeleteAsync(taskId, cancellationToken); await BroadcastTaskUpdatedAsync(taskId, cancellationToken); await BroadcastTaskUpdatedAsync(ctx.ParentTaskId, cancellationToken); } [McpServerTool, Description("Update the title and/or description of the parent planning task itself.")] public async Task UpdatePlanningTask( string? title, string? description, CancellationToken cancellationToken) { var ctx = _contextAccessor.Current; await _tasks.UpdatePlanningTaskAsync(ctx.ParentTaskId, title, description, cancellationToken); await BroadcastTaskUpdatedAsync(ctx.ParentTaskId, cancellationToken); } [McpServerTool, Description("Finalize the planning session. Child tasks are left idle and chain-linked (each blocked by its predecessor); they are NOT queued automatically — the user queues the plan from the app when ready. The queueAgentTasks argument is accepted for compatibility but ignored.")] public async Task Finalize( bool queueAgentTasks, CancellationToken cancellationToken) { var ctx = _contextAccessor.Current; var finalizeResult = await _state.FinalizePlanningAsync(ctx.ParentTaskId, cancellationToken); if (!finalizeResult.Ok) throw new InvalidOperationException( finalizeResult.Reason ?? $"Could not finalize planning for task {ctx.ParentTaskId}."); var children = await _tasks.GetChildrenAsync(ctx.ParentTaskId, cancellationToken); int count = children.Count; // Establish the blocked-by chain but leave children Idle; queueing is a // deliberate user action ("Queue plan"), never an automatic finalize step. if (children.Count > 0) count = await _chain.SetupChainAsync(ctx.ParentTaskId, enqueue: false, cancellationToken); foreach (var c in children) await BroadcastTaskUpdatedAsync(c.Id, cancellationToken); await BroadcastTaskUpdatedAsync(ctx.ParentTaskId, cancellationToken); return count; } }