1// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
2//
3// SPDX-License-Identifier: AGPL-3.0-or-later
4
5// Package tasks provides MCP tools for task management in Lunatask.
6package tasks
7
8import (
9 "context"
10 "fmt"
11
12 "git.secluded.site/go-lunatask"
13 "github.com/modelcontextprotocol/go-sdk/mcp"
14
15 "git.sr.ht/~amolith/lunatask-mcp-server/tools/shared"
16)
17
18// MaxNameLength is the maximum allowed task name length.
19const MaxNameLength = 100
20
21// Handler handles task-related MCP tool calls.
22type Handler struct {
23 client *lunatask.Client
24 timezone string
25 areas []shared.AreaProvider
26}
27
28// NewHandler creates a new tasks Handler.
29func NewHandler(
30 accessToken string,
31 timezone string,
32 areas []shared.AreaProvider,
33) *Handler {
34 return &Handler{
35 client: lunatask.NewClient(accessToken),
36 timezone: timezone,
37 areas: areas,
38 }
39}
40
41// HandleCreate handles the create_task tool call.
42func (h *Handler) HandleCreate(
43 ctx context.Context,
44 _ *mcp.CallToolRequest,
45 input CreateInput,
46) (*mcp.CallToolResult, CreateOutput, error) {
47 if _, err := shared.LoadLocation(h.timezone); err != nil {
48 return nil, CreateOutput{}, err
49 }
50
51 if len(input.Name) > MaxNameLength {
52 return nil, CreateOutput{}, fmt.Errorf("name must be %d characters or fewer", MaxNameLength)
53 }
54
55 area := shared.FindArea(h.areas, input.AreaID)
56 if area == nil {
57 return nil, CreateOutput{}, fmt.Errorf("area not found: %s", input.AreaID)
58 }
59
60 // Resolve goal key to ID if provided
61 var goalID string
62 if input.GoalID != nil && *input.GoalID != "" {
63 goal := shared.GetGoalInArea(area, *input.GoalID)
64 if goal == nil {
65 return nil, CreateOutput{}, fmt.Errorf(
66 "goal %s not found in area %s",
67 *input.GoalID,
68 area.GetName(),
69 )
70 }
71 goalID = goal.GetID()
72 }
73
74 builder := h.client.NewTask(input.Name).InArea(area.GetID())
75
76 if err := h.applyCreateOptions(builder, input, goalID); err != nil {
77 return nil, CreateOutput{}, err
78 }
79
80 task, err := builder.Create(ctx)
81 if err != nil {
82 return nil, CreateOutput{}, fmt.Errorf("failed to create task: %w", err)
83 }
84
85 // Handle nil response (task already exists)
86 if task == nil {
87 return nil, CreateOutput{
88 Message: "Task already exists (not an error)",
89 }, nil
90 }
91
92 deepLink, _ := lunatask.BuildDeepLink(lunatask.ResourceTask, task.ID)
93
94 return nil, CreateOutput{
95 TaskID: task.ID,
96 Message: "Task created successfully",
97 DeepLink: deepLink,
98 }, nil
99}
100
101// HandleUpdate handles the update_task tool call.
102func (h *Handler) HandleUpdate(
103 ctx context.Context,
104 _ *mcp.CallToolRequest,
105 input UpdateInput,
106) (*mcp.CallToolResult, UpdateOutput, error) {
107 if _, err := shared.LoadLocation(h.timezone); err != nil {
108 return nil, UpdateOutput{}, err
109 }
110
111 builder := h.client.NewTaskUpdate(input.TaskID)
112
113 if err := h.applyUpdateOptions(builder, input); err != nil {
114 return nil, UpdateOutput{}, err
115 }
116
117 task, err := builder.Update(ctx)
118 if err != nil {
119 return nil, UpdateOutput{}, fmt.Errorf("failed to update task: %w", err)
120 }
121
122 deepLink, _ := lunatask.BuildDeepLink(lunatask.ResourceTask, task.ID)
123
124 return nil, UpdateOutput{
125 TaskID: task.ID,
126 Message: "Task updated successfully",
127 DeepLink: deepLink,
128 }, nil
129}
130
131// HandleDelete handles the delete_task tool call.
132func (h *Handler) HandleDelete(
133 ctx context.Context,
134 _ *mcp.CallToolRequest,
135 input DeleteInput,
136) (*mcp.CallToolResult, DeleteOutput, error) {
137 _, err := h.client.DeleteTask(ctx, input.TaskID)
138 if err != nil {
139 return nil, DeleteOutput{}, fmt.Errorf("failed to delete task: %w", err)
140 }
141
142 return nil, DeleteOutput{Message: "Task deleted successfully"}, nil
143}
144
145// Areas returns the list of configured areas for resource listing.
146func (h *Handler) Areas() []shared.AreaProvider {
147 return h.areas
148}
149
150// applyCreateOptions applies optional fields to a TaskBuilder.
151//
152//nolint:funlen // each field handling is straightforward
153func (h *Handler) applyCreateOptions(builder *lunatask.TaskBuilder, input CreateInput, goalID string) error {
154 if goalID != "" {
155 builder.InGoal(goalID)
156 }
157
158 if input.Note != nil {
159 builder.WithNote(*input.Note)
160 }
161
162 if input.Estimate != nil {
163 builder.WithEstimate(*input.Estimate)
164 }
165
166 if input.Priority != nil {
167 p, err := lunatask.ParsePriority(*input.Priority)
168 if err != nil {
169 return fmt.Errorf("invalid priority: %w", err)
170 }
171
172 builder.Priority(p)
173 }
174
175 if input.Motivation != nil {
176 m, err := lunatask.ParseMotivation(*input.Motivation)
177 if err != nil {
178 return fmt.Errorf("invalid motivation: %w", err)
179 }
180
181 builder.WithMotivation(m)
182 }
183
184 if input.Eisenhower != nil {
185 e, err := lunatask.ParseEisenhower(*input.Eisenhower)
186 if err != nil {
187 return fmt.Errorf("invalid eisenhower: %w", err)
188 }
189
190 builder.WithEisenhower(e)
191 }
192
193 if input.Status != nil {
194 s, err := lunatask.ParseTaskStatus(*input.Status)
195 if err != nil {
196 return fmt.Errorf("invalid status: %w", err)
197 }
198
199 builder.WithStatus(s)
200 }
201
202 if input.ScheduledOn != nil && *input.ScheduledOn != "" {
203 date, err := lunatask.ParseDate(*input.ScheduledOn)
204 if err != nil {
205 return fmt.Errorf("invalid scheduled_on date: %w", err)
206 }
207
208 builder.ScheduledOn(date)
209 }
210
211 if input.Source != nil && *input.Source != "" {
212 sourceID := ""
213 if input.SourceID != nil {
214 sourceID = *input.SourceID
215 }
216
217 builder.FromSource(*input.Source, sourceID)
218 }
219
220 return nil
221}
222
223// applyUpdateOptions applies optional fields to a TaskUpdateBuilder.
224//
225//nolint:funlen,gocognit // each field handling is straightforward
226func (h *Handler) applyUpdateOptions(builder *lunatask.TaskUpdateBuilder, input UpdateInput) error {
227 var resolvedAreaID string
228 var resolvedGoalID string
229
230 if input.AreaID != nil && *input.AreaID != "" {
231 area := shared.FindArea(h.areas, *input.AreaID)
232 if area == nil {
233 return fmt.Errorf("area not found: %s", *input.AreaID)
234 }
235
236 resolvedAreaID = area.GetID()
237 builder.InArea(resolvedAreaID)
238
239 // Validate and resolve goal if also being set
240 if input.GoalID != nil && *input.GoalID != "" {
241 goal := shared.GetGoalInArea(area, *input.GoalID)
242 if goal == nil {
243 return fmt.Errorf("goal %s not found in area %s", *input.GoalID, area.GetName())
244 }
245 resolvedGoalID = goal.GetID()
246 }
247 }
248
249 if input.GoalID != nil && *input.GoalID != "" {
250 if resolvedGoalID != "" {
251 // Already resolved above with area context
252 builder.InGoal(resolvedGoalID)
253 } else {
254 // No area context - try to resolve across all areas
255 for _, area := range h.areas {
256 if goal := shared.GetGoalInArea(area, *input.GoalID); goal != nil {
257 builder.InGoal(goal.GetID())
258 break
259 }
260 }
261 }
262 }
263
264 if input.Name != nil {
265 if len(*input.Name) > MaxNameLength {
266 return fmt.Errorf("name must be %d characters or fewer", MaxNameLength)
267 }
268
269 builder.Name(*input.Name)
270 }
271
272 if input.Note != nil {
273 builder.WithNote(*input.Note)
274 }
275
276 if input.Estimate != nil {
277 builder.WithEstimate(*input.Estimate)
278 }
279
280 if input.Priority != nil {
281 p, err := lunatask.ParsePriority(*input.Priority)
282 if err != nil {
283 return fmt.Errorf("invalid priority: %w", err)
284 }
285
286 builder.Priority(p)
287 }
288
289 if input.Motivation != nil {
290 m, err := lunatask.ParseMotivation(*input.Motivation)
291 if err != nil {
292 return fmt.Errorf("invalid motivation: %w", err)
293 }
294
295 builder.WithMotivation(m)
296 }
297
298 if input.Eisenhower != nil {
299 e, err := lunatask.ParseEisenhower(*input.Eisenhower)
300 if err != nil {
301 return fmt.Errorf("invalid eisenhower: %w", err)
302 }
303
304 builder.WithEisenhower(e)
305 }
306
307 if input.Status != nil {
308 s, err := lunatask.ParseTaskStatus(*input.Status)
309 if err != nil {
310 return fmt.Errorf("invalid status: %w", err)
311 }
312
313 builder.WithStatus(s)
314 }
315
316 if input.ScheduledOn != nil && *input.ScheduledOn != "" {
317 date, err := lunatask.ParseDate(*input.ScheduledOn)
318 if err != nil {
319 return fmt.Errorf("invalid scheduled_on date: %w", err)
320 }
321
322 builder.ScheduledOn(date)
323 }
324
325 return nil
326}