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