1// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
2//
3// SPDX-License-Identifier: AGPL-3.0-or-later
4
5package tools
6
7import (
8 "context"
9 "fmt"
10 "strings"
11
12 "git.secluded.site/go-lunatask"
13 "github.com/mark3labs/mcp-go/mcp"
14)
15
16// HandleCreateTask handles the create_task tool call.
17func (h *Handlers) HandleCreateTask(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
18 arguments := request.Params.Arguments
19
20 if _, err := LoadLocation(h.config.Timezone); err != nil {
21 return reportMCPError(err.Error())
22 }
23
24 areaID, ok := arguments["area_id"].(string)
25 if !ok || areaID == "" {
26 return reportMCPError("Missing or invalid required argument: area_id")
27 }
28
29 var areaFoundProvider AreaProvider
30
31 for _, ap := range h.config.Areas {
32 if ap.GetID() == areaID {
33 areaFoundProvider = ap
34
35 break
36 }
37 }
38
39 if areaFoundProvider == nil {
40 return reportMCPError("Area not found for given area_id")
41 }
42
43 var goalID *string
44 if goalIDStr, exists := arguments["goal_id"].(string); exists && goalIDStr != "" {
45 found := false
46
47 for _, goal := range areaFoundProvider.GetGoals() {
48 if goal.GetID() == goalIDStr {
49 found = true
50
51 break
52 }
53 }
54
55 if !found {
56 return reportMCPError("Goal not found in specified area for given goal_id")
57 }
58
59 goalID = &goalIDStr
60 }
61
62 name, ok := arguments["name"].(string)
63 if !ok || name == "" {
64 return reportMCPError("Missing or invalid required argument: name")
65 }
66
67 if len(name) > 100 {
68 return reportMCPError("'name' must be 100 characters or fewer")
69 }
70
71 task := lunatask.CreateTaskRequest{
72 Name: name,
73 AreaID: &areaID,
74 GoalID: goalID,
75 }
76
77 if noteVal, exists := arguments["note"].(string); exists {
78 task.Note = ¬eVal
79 }
80
81 if priorityArg, exists := arguments["priority"]; exists && priorityArg != nil {
82 priorityStr, ok := priorityArg.(string)
83 if !ok {
84 return reportMCPError("Invalid type for 'priority' argument: expected string.")
85 }
86
87 priorityMap := map[string]int{
88 "lowest": -2,
89 "low": -1,
90 "neutral": 0,
91 "high": 1,
92 "highest": 2,
93 }
94
95 translatedPriority, isValid := priorityMap[strings.ToLower(priorityStr)]
96 if !isValid {
97 return reportMCPError(fmt.Sprintf("Invalid 'priority' value: '%s'. Must be one of 'lowest', 'low', 'neutral', 'high', 'highest'.", priorityStr))
98 }
99
100 task.Priority = &translatedPriority
101 }
102
103 if eisenhowerArg, exists := arguments["eisenhower"]; exists && eisenhowerArg != nil {
104 eisenhowerStr, ok := eisenhowerArg.(string)
105 if !ok {
106 return reportMCPError("Invalid type for 'eisenhower' argument: expected string.")
107 }
108
109 eisenhowerMap := map[string]int{
110 "uncategorised": 0,
111 "both urgent and important": 1,
112 "urgent, but not important": 2,
113 "important, but not urgent": 3,
114 "neither urgent nor important": 4,
115 }
116
117 translatedEisenhower, isValid := eisenhowerMap[strings.ToLower(eisenhowerStr)]
118 if !isValid {
119 return reportMCPError(fmt.Sprintf("Invalid 'eisenhower' value: '%s'. Must be one of 'uncategorised', 'both urgent and important', 'urgent, but not important', 'important, but not urgent', 'neither urgent nor important'.", eisenhowerStr))
120 }
121
122 task.Eisenhower = &translatedEisenhower
123 }
124
125 if motivationVal, exists := arguments["motivation"]; exists && motivationVal != nil {
126 motivation, ok := motivationVal.(string)
127 if !ok {
128 return reportMCPError("'motivation' must be a string")
129 }
130
131 if motivation != "" {
132 validMotivations := map[string]bool{"must": true, "should": true, "want": true}
133 if !validMotivations[motivation] {
134 return reportMCPError("'motivation' must be one of 'must', 'should', or 'want'")
135 }
136
137 task.Motivation = &motivation
138 }
139 }
140
141 if statusVal, exists := arguments["status"]; exists && statusVal != nil {
142 status, ok := statusVal.(string)
143 if !ok {
144 return reportMCPError("'status' must be a string")
145 }
146
147 if status != "" {
148 validStatus := map[string]bool{"later": true, "next": true, "started": true, "waiting": true, "completed": true}
149 if !validStatus[status] {
150 return reportMCPError("'status' must be one of 'later', 'next', 'started', 'waiting', or 'completed'")
151 }
152
153 task.Status = &status
154 }
155 }
156
157 if estimateArg, exists := arguments["estimate"]; exists && estimateArg != nil {
158 estimateVal, ok := estimateArg.(float64)
159 if !ok {
160 return reportMCPError("Invalid type for 'estimate' argument: expected number.")
161 }
162
163 estimate := int(estimateVal)
164 if estimate < 0 || estimate > 720 {
165 return reportMCPError("'estimate' must be between 0 and 720 minutes")
166 }
167
168 task.Estimate = &estimate
169 }
170
171 if scheduledOnArg, exists := arguments["scheduled_on"]; exists {
172 scheduledOnStr, ok := scheduledOnArg.(string)
173 if !ok {
174 return reportMCPError("Invalid type for scheduled_on argument: expected string.")
175 }
176
177 if scheduledOnStr != "" {
178 date, err := lunatask.ParseDate(scheduledOnStr)
179 if err != nil {
180 return reportMCPError(fmt.Sprintf("Invalid format for scheduled_on: '%s'. Must be YYYY-MM-DD.", scheduledOnStr))
181 }
182
183 task.ScheduledOn = &date
184 }
185 }
186
187 if sourceArg, exists := arguments["source"].(string); exists && sourceArg != "" {
188 task.Source = &sourceArg
189 }
190
191 if sourceIDArg, exists := arguments["source_id"].(string); exists && sourceIDArg != "" {
192 task.SourceID = &sourceIDArg
193 }
194
195 client := lunatask.NewClient(h.config.AccessToken)
196
197 response, err := client.CreateTask(ctx, &task)
198 if err != nil {
199 return reportMCPError(fmt.Sprintf("%v", err))
200 }
201
202 if response == nil {
203 return &mcp.CallToolResult{
204 Content: []mcp.Content{
205 mcp.TextContent{
206 Type: "text",
207 Text: "Task already exists (not an error).",
208 },
209 },
210 }, nil
211 }
212
213 return &mcp.CallToolResult{
214 Content: []mcp.Content{
215 mcp.TextContent{
216 Type: "text",
217 Text: "Task created successfully with ID: " + response.ID,
218 },
219 },
220 }, nil
221}
222
223// HandleUpdateTask handles the update_task tool call.
224func (h *Handlers) HandleUpdateTask(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
225 arguments := request.Params.Arguments
226
227 taskID, ok := arguments["task_id"].(string)
228 if !ok || taskID == "" {
229 return reportMCPError("Missing or invalid required argument: task_id")
230 }
231
232 if _, err := LoadLocation(h.config.Timezone); err != nil {
233 return reportMCPError(err.Error())
234 }
235
236 updatePayload := lunatask.UpdateTaskRequest{}
237
238 var specifiedAreaProvider AreaProvider
239
240 areaIDProvided := false
241
242 if areaIDArg, exists := arguments["area_id"]; exists {
243 areaIDStr, ok := areaIDArg.(string)
244 if !ok && areaIDArg != nil {
245 return reportMCPError("Invalid type for area_id argument: expected string.")
246 }
247
248 if ok && areaIDStr != "" {
249 updatePayload.AreaID = &areaIDStr
250 areaIDProvided = true
251 found := false
252
253 for _, ap := range h.config.Areas {
254 if ap.GetID() == areaIDStr {
255 specifiedAreaProvider = ap
256 found = true
257
258 break
259 }
260 }
261
262 if !found {
263 return reportMCPError("Area not found for given area_id: " + areaIDStr)
264 }
265 }
266 }
267
268 if goalIDArg, exists := arguments["goal_id"]; exists {
269 goalIDStr, ok := goalIDArg.(string)
270 if !ok && goalIDArg != nil {
271 return reportMCPError("Invalid type for goal_id argument: expected string.")
272 }
273
274 if ok && goalIDStr != "" {
275 updatePayload.GoalID = &goalIDStr
276
277 if specifiedAreaProvider != nil {
278 foundGoal := false
279
280 for _, goal := range specifiedAreaProvider.GetGoals() {
281 if goal.GetID() == goalIDStr {
282 foundGoal = true
283
284 break
285 }
286 }
287
288 if !foundGoal {
289 return reportMCPError(fmt.Sprintf("Goal not found in specified area '%s' for given goal_id: %s", specifiedAreaProvider.GetName(), goalIDStr))
290 }
291 } else if areaIDProvided {
292 return reportMCPError("Internal error: area_id provided but area details not loaded for goal validation.")
293 }
294 }
295 }
296
297 nameArg := arguments["name"]
298 if nameStr, ok := nameArg.(string); ok {
299 if len(nameStr) > 100 {
300 return reportMCPError("'name' must be 100 characters or fewer")
301 }
302
303 updatePayload.Name = &nameStr
304 } else {
305 return reportMCPError("Invalid type for name argument: expected string.")
306 }
307
308 if noteArg, exists := arguments["note"]; exists {
309 noteStr, ok := noteArg.(string)
310 if !ok && noteArg != nil {
311 return reportMCPError("Invalid type for note argument: expected string.")
312 }
313
314 if ok {
315 updatePayload.Note = ¬eStr
316 }
317 }
318
319 if estimateArg, exists := arguments["estimate"]; exists && estimateArg != nil {
320 estimateVal, ok := estimateArg.(float64)
321 if !ok {
322 return reportMCPError("Invalid type for estimate argument: expected number.")
323 }
324
325 estimate := int(estimateVal)
326 if estimate < 0 || estimate > 720 {
327 return reportMCPError("'estimate' must be between 0 and 720 minutes")
328 }
329
330 updatePayload.Estimate = &estimate
331 }
332
333 if priorityArg, exists := arguments["priority"]; exists && priorityArg != nil {
334 priorityStr, ok := priorityArg.(string)
335 if !ok {
336 return reportMCPError("Invalid type for 'priority' argument: expected string.")
337 }
338
339 priorityMap := map[string]int{
340 "lowest": -2,
341 "low": -1,
342 "neutral": 0,
343 "high": 1,
344 "highest": 2,
345 }
346
347 translatedPriority, isValid := priorityMap[strings.ToLower(priorityStr)]
348 if !isValid {
349 return reportMCPError(fmt.Sprintf("Invalid 'priority' value: '%s'. Must be one of 'lowest', 'low', 'neutral', 'high', 'highest'.", priorityStr))
350 }
351
352 updatePayload.Priority = &translatedPriority
353 }
354
355 if eisenhowerArg, exists := arguments["eisenhower"]; exists && eisenhowerArg != nil {
356 eisenhowerStr, ok := eisenhowerArg.(string)
357 if !ok {
358 return reportMCPError("Invalid type for 'eisenhower' argument: expected string.")
359 }
360
361 eisenhowerMap := map[string]int{
362 "uncategorised": 0,
363 "both urgent and important": 1,
364 "urgent, but not important": 2,
365 "important, but not urgent": 3,
366 "neither urgent nor important": 4,
367 }
368
369 translatedEisenhower, isValid := eisenhowerMap[strings.ToLower(eisenhowerStr)]
370 if !isValid {
371 return reportMCPError(fmt.Sprintf("Invalid 'eisenhower' value: '%s'. Must be one of 'uncategorised', 'both urgent and important', 'urgent, but not important', 'important, but not urgent', 'neither urgent nor important'.", eisenhowerStr))
372 }
373
374 updatePayload.Eisenhower = &translatedEisenhower
375 }
376
377 if motivationArg, exists := arguments["motivation"]; exists {
378 motivationStr, ok := motivationArg.(string)
379 if !ok && motivationArg != nil {
380 return reportMCPError("Invalid type for motivation argument: expected string.")
381 }
382
383 if ok {
384 if motivationStr != "" {
385 validMotivations := map[string]bool{"must": true, "should": true, "want": true}
386 if !validMotivations[motivationStr] {
387 return reportMCPError("'motivation' must be one of 'must', 'should', or 'want', or empty to clear.")
388 }
389 }
390
391 updatePayload.Motivation = &motivationStr
392 }
393 }
394
395 if statusArg, exists := arguments["status"]; exists {
396 statusStr, ok := statusArg.(string)
397 if !ok && statusArg != nil {
398 return reportMCPError("Invalid type for status argument: expected string.")
399 }
400
401 if ok {
402 if statusStr != "" {
403 validStatus := map[string]bool{"later": true, "next": true, "started": true, "waiting": true, "completed": true}
404 if !validStatus[statusStr] {
405 return reportMCPError("'status' must be one of 'later', 'next', 'started', 'waiting', 'completed', or empty.")
406 }
407 }
408
409 updatePayload.Status = &statusStr
410 }
411 }
412
413 if scheduledOnArg, exists := arguments["scheduled_on"]; exists {
414 scheduledOnStr, ok := scheduledOnArg.(string)
415 if !ok && scheduledOnArg != nil {
416 return reportMCPError("Invalid type for scheduled_on argument: expected string.")
417 }
418
419 if ok && scheduledOnStr != "" {
420 date, err := lunatask.ParseDate(scheduledOnStr)
421 if err != nil {
422 return reportMCPError(fmt.Sprintf("Invalid format for scheduled_on: '%s'. Must be YYYY-MM-DD.", scheduledOnStr))
423 }
424
425 updatePayload.ScheduledOn = &date
426 }
427 }
428
429 client := lunatask.NewClient(h.config.AccessToken)
430
431 response, err := client.UpdateTask(ctx, taskID, &updatePayload)
432 if err != nil {
433 return reportMCPError(fmt.Sprintf("Failed to update task: %v", err))
434 }
435
436 return &mcp.CallToolResult{
437 Content: []mcp.Content{
438 mcp.TextContent{
439 Type: "text",
440 Text: "Task updated successfully. ID: " + response.ID,
441 },
442 },
443 }, nil
444}
445
446// HandleDeleteTask handles the delete_task tool call.
447func (h *Handlers) HandleDeleteTask(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
448 taskID, ok := request.Params.Arguments["task_id"].(string)
449 if !ok || taskID == "" {
450 return reportMCPError("Missing or invalid required argument: task_id")
451 }
452
453 client := lunatask.NewClient(h.config.AccessToken)
454
455 _, err := client.DeleteTask(ctx, taskID)
456 if err != nil {
457 return reportMCPError(fmt.Sprintf("Failed to delete task: %v", err))
458 }
459
460 return &mcp.CallToolResult{
461 Content: []mcp.Content{
462 mcp.TextContent{
463 Type: "text",
464 Text: "Task deleted successfully.",
465 },
466 },
467 }, nil
468}