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/mark3labs/mcp-go/mcp"
14
15 "git.sr.ht/~amolith/lunatask-mcp-server/tools/shared"
16)
17
18// Handler handles task-related MCP tool calls.
19type Handler struct {
20 accessToken string
21 timezone string
22 areas []shared.AreaProvider
23}
24
25// NewHandler creates a new tasks Handler.
26func NewHandler(
27 accessToken string,
28 timezone string,
29 areas []shared.AreaProvider,
30) *Handler {
31 return &Handler{
32 accessToken: accessToken,
33 timezone: timezone,
34 areas: areas,
35 }
36}
37
38// HandleCreate handles the create_task tool call.
39//
40//nolint:cyclop,funlen,wrapcheck // validation complexity; ReportError returns nil
41func (h *Handler) HandleCreate(
42 ctx context.Context,
43 request mcp.CallToolRequest,
44) (*mcp.CallToolResult, error) {
45 arguments := request.Params.Arguments
46
47 if _, err := shared.LoadLocation(h.timezone); err != nil {
48 return shared.ReportError(err.Error())
49 }
50
51 areaID, ok := arguments["area_id"].(string)
52 if !ok || areaID == "" {
53 return shared.ReportError("Missing or invalid required argument: area_id")
54 }
55
56 area := FindArea(h.areas, areaID)
57 if area == nil {
58 return shared.ReportError("Area not found for given area_id")
59 }
60
61 goalID, errResult := h.validateGoalID(arguments, area)
62 if errResult != nil {
63 return errResult, nil
64 }
65
66 name, ok := arguments["name"].(string)
67 if !ok || name == "" {
68 return shared.ReportError("Missing or invalid required argument: name")
69 }
70
71 if errResult := ValidateName(name); errResult != nil {
72 return errResult, nil
73 }
74
75 task := lunatask.CreateTaskRequest{
76 Name: name,
77 AreaID: &areaID,
78 GoalID: goalID,
79 }
80
81 if err := h.populateCreateFields(&task, arguments); err != nil {
82 return err, nil
83 }
84
85 client := lunatask.NewClient(h.accessToken)
86
87 response, err := client.CreateTask(ctx, &task)
88 if err != nil {
89 return shared.ReportError(fmt.Sprintf("%v", err))
90 }
91
92 if response == nil {
93 return &mcp.CallToolResult{
94 Content: []mcp.Content{
95 mcp.TextContent{
96 Type: "text",
97 Text: "Task already exists (not an error).",
98 },
99 },
100 }, nil
101 }
102
103 return &mcp.CallToolResult{
104 Content: []mcp.Content{
105 mcp.TextContent{
106 Type: "text",
107 Text: "Task created successfully with ID: " + response.ID,
108 },
109 },
110 }, nil
111}
112
113// HandleUpdate handles the update_task tool call.
114//
115//nolint:wrapcheck // ReportError returns nil
116func (h *Handler) HandleUpdate(
117 ctx context.Context,
118 request mcp.CallToolRequest,
119) (*mcp.CallToolResult, error) {
120 arguments := request.Params.Arguments
121
122 taskID, ok := arguments["task_id"].(string)
123 if !ok || taskID == "" {
124 return shared.ReportError("Missing or invalid required argument: task_id")
125 }
126
127 if _, err := shared.LoadLocation(h.timezone); err != nil {
128 return shared.ReportError(err.Error())
129 }
130
131 updatePayload := lunatask.UpdateTaskRequest{}
132
133 area, errResult := h.validateUpdateArea(arguments, &updatePayload)
134 if errResult != nil {
135 return errResult, nil
136 }
137
138 if errResult := h.validateUpdateGoal(arguments, area, &updatePayload); errResult != nil {
139 return errResult, nil
140 }
141
142 if errResult := h.validateUpdateName(arguments, &updatePayload); errResult != nil {
143 return errResult, nil
144 }
145
146 if err := h.populateUpdateFields(&updatePayload, arguments); err != nil {
147 return err, nil
148 }
149
150 client := lunatask.NewClient(h.accessToken)
151
152 response, err := client.UpdateTask(ctx, taskID, &updatePayload)
153 if err != nil {
154 return shared.ReportError(fmt.Sprintf("Failed to update task: %v", err))
155 }
156
157 return &mcp.CallToolResult{
158 Content: []mcp.Content{
159 mcp.TextContent{
160 Type: "text",
161 Text: "Task updated successfully. ID: " + response.ID,
162 },
163 },
164 }, nil
165}
166
167// HandleDelete handles the delete_task tool call.
168//
169//nolint:wrapcheck // ReportError returns nil
170func (h *Handler) HandleDelete(
171 ctx context.Context,
172 request mcp.CallToolRequest,
173) (*mcp.CallToolResult, error) {
174 taskID, ok := request.Params.Arguments["task_id"].(string)
175 if !ok || taskID == "" {
176 return shared.ReportError("Missing or invalid required argument: task_id")
177 }
178
179 client := lunatask.NewClient(h.accessToken)
180
181 _, err := client.DeleteTask(ctx, taskID)
182 if err != nil {
183 return shared.ReportError(fmt.Sprintf("Failed to delete task: %v", err))
184 }
185
186 return &mcp.CallToolResult{
187 Content: []mcp.Content{
188 mcp.TextContent{
189 Type: "text",
190 Text: "Task deleted successfully.",
191 },
192 },
193 }, nil
194}
195
196// validateGoalID validates and returns the goal_id if provided.
197func (h *Handler) validateGoalID(
198 arguments map[string]any,
199 area shared.AreaProvider,
200) (*string, *mcp.CallToolResult) {
201 goalIDStr, exists := arguments["goal_id"].(string)
202 if !exists || goalIDStr == "" {
203 return nil, nil
204 }
205
206 if !FindGoalInArea(area, goalIDStr) {
207 result, _ := shared.ReportError(
208 "Goal not found in specified area for given goal_id",
209 )
210
211 return nil, result
212 }
213
214 return &goalIDStr, nil
215}
216
217// validateUpdateArea validates area_id for update and returns the area provider.
218//
219//nolint:ireturn // returns interface by design
220func (h *Handler) validateUpdateArea(
221 arguments map[string]any,
222 payload *lunatask.UpdateTaskRequest,
223) (shared.AreaProvider, *mcp.CallToolResult) {
224 areaIDArg, exists := arguments["area_id"]
225 if !exists {
226 return nil, nil
227 }
228
229 areaIDStr, ok := areaIDArg.(string)
230 if !ok && areaIDArg != nil {
231 result, _ := shared.ReportError(
232 "Invalid type for area_id argument: expected string.",
233 )
234
235 return nil, result
236 }
237
238 if !ok || areaIDStr == "" {
239 return nil, nil
240 }
241
242 payload.AreaID = &areaIDStr
243 area := FindArea(h.areas, areaIDStr)
244
245 if area == nil {
246 result, _ := shared.ReportError("Area not found for given area_id: " + areaIDStr)
247
248 return nil, result
249 }
250
251 return area, nil
252}
253
254// validateUpdateGoal validates goal_id for update.
255func (h *Handler) validateUpdateGoal(
256 arguments map[string]any,
257 area shared.AreaProvider,
258 payload *lunatask.UpdateTaskRequest,
259) *mcp.CallToolResult {
260 goalIDArg, exists := arguments["goal_id"]
261 if !exists {
262 return nil
263 }
264
265 goalIDStr, ok := goalIDArg.(string)
266 if !ok && goalIDArg != nil {
267 result, _ := shared.ReportError(
268 "Invalid type for goal_id argument: expected string.",
269 )
270
271 return result
272 }
273
274 if !ok || goalIDStr == "" {
275 return nil
276 }
277
278 payload.GoalID = &goalIDStr
279
280 if area != nil && !FindGoalInArea(area, goalIDStr) {
281 result, _ := shared.ReportError(fmt.Sprintf(
282 "Goal not found in specified area '%s' for given goal_id: %s",
283 area.GetName(),
284 goalIDStr,
285 ))
286
287 return result
288 }
289
290 return nil
291}
292
293// validateUpdateName validates and sets the name for update.
294func (h *Handler) validateUpdateName(
295 arguments map[string]any,
296 payload *lunatask.UpdateTaskRequest,
297) *mcp.CallToolResult {
298 nameArg := arguments["name"]
299 nameStr, ok := nameArg.(string)
300
301 if !ok {
302 result, _ := shared.ReportError(
303 "Invalid type for name argument: expected string.",
304 )
305
306 return result
307 }
308
309 if errResult := ValidateName(nameStr); errResult != nil {
310 return errResult
311 }
312
313 payload.Name = &nameStr
314
315 return nil
316}
317
318// populateCreateFields populates optional fields for task creation.
319func (h *Handler) populateCreateFields(
320 task *lunatask.CreateTaskRequest,
321 arguments map[string]any,
322) *mcp.CallToolResult {
323 if noteVal, exists := arguments["note"].(string); exists {
324 task.Note = ¬eVal
325 }
326
327 if errResult := h.setCreatePriority(task, arguments); errResult != nil {
328 return errResult
329 }
330
331 if errResult := h.setCreateEisenhower(task, arguments); errResult != nil {
332 return errResult
333 }
334
335 if errResult := h.setCreateMotivation(task, arguments); errResult != nil {
336 return errResult
337 }
338
339 if errResult := h.setCreateStatus(task, arguments); errResult != nil {
340 return errResult
341 }
342
343 if errResult := h.setCreateEstimate(task, arguments); errResult != nil {
344 return errResult
345 }
346
347 if errResult := h.setCreateScheduledOn(task, arguments); errResult != nil {
348 return errResult
349 }
350
351 h.setCreateSource(task, arguments)
352
353 return nil
354}
355
356// populateUpdateFields populates optional fields for task update.
357func (h *Handler) populateUpdateFields(
358 payload *lunatask.UpdateTaskRequest,
359 arguments map[string]any,
360) *mcp.CallToolResult {
361 if errResult := h.setUpdateNote(payload, arguments); errResult != nil {
362 return errResult
363 }
364
365 if errResult := h.setUpdateEstimate(payload, arguments); errResult != nil {
366 return errResult
367 }
368
369 if errResult := h.setUpdatePriority(payload, arguments); errResult != nil {
370 return errResult
371 }
372
373 if errResult := h.setUpdateEisenhower(payload, arguments); errResult != nil {
374 return errResult
375 }
376
377 if errResult := h.setUpdateMotivation(payload, arguments); errResult != nil {
378 return errResult
379 }
380
381 if errResult := h.setUpdateStatus(payload, arguments); errResult != nil {
382 return errResult
383 }
384
385 if errResult := h.setUpdateScheduledOn(payload, arguments); errResult != nil {
386 return errResult
387 }
388
389 return nil
390}