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