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 "time"
12
13 "git.sr.ht/~amolith/lunatask-mcp-server/lunatask"
14 "github.com/mark3labs/mcp-go/mcp"
15)
16
17// HandleCreateTask handles the create_task tool call.
18func (h *Handlers) HandleCreateTask(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
19 arguments := request.Params.Arguments
20
21 if _, err := LoadLocation(h.config.Timezone); err != nil {
22 return reportMCPError(err.Error())
23 }
24
25 areaID, ok := arguments["area_id"].(string)
26 if !ok || areaID == "" {
27 return reportMCPError("Missing or invalid required argument: area_id")
28 }
29
30 var areaFoundProvider AreaProvider
31 for _, ap := range h.config.Areas {
32 if ap.GetID() == areaID {
33 areaFoundProvider = ap
34 break
35 }
36 }
37 if areaFoundProvider == nil {
38 return reportMCPError("Area not found for given area_id")
39 }
40
41 var goalID *string
42 if goalIDStr, exists := arguments["goal_id"].(string); exists && goalIDStr != "" {
43 found := false
44 for _, goal := range areaFoundProvider.GetGoals() {
45 if goal.GetID() == goalIDStr {
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 goalID = &goalIDStr
54 }
55
56 name, ok := arguments["name"].(string)
57 if !ok || name == "" {
58 return reportMCPError("Missing or invalid required argument: name")
59 }
60 if len(name) > 100 {
61 return reportMCPError("'name' must be 100 characters or fewer")
62 }
63
64 task := lunatask.CreateTaskRequest{
65 Name: name,
66 AreaID: &areaID,
67 GoalID: goalID,
68 }
69
70 if noteVal, exists := arguments["note"].(string); exists {
71 task.Note = ¬eVal
72 }
73
74 if priorityArg, exists := arguments["priority"]; exists && priorityArg != nil {
75 priorityStr, ok := priorityArg.(string)
76 if !ok {
77 return reportMCPError("Invalid type for 'priority' argument: expected string.")
78 }
79 priorityMap := map[string]int{
80 "lowest": -2,
81 "low": -1,
82 "neutral": 0,
83 "high": 1,
84 "highest": 2,
85 }
86 translatedPriority, isValid := priorityMap[strings.ToLower(priorityStr)]
87 if !isValid {
88 return reportMCPError(fmt.Sprintf("Invalid 'priority' value: '%s'. Must be one of 'lowest', 'low', 'neutral', 'high', 'highest'.", priorityStr))
89 }
90 task.Priority = &translatedPriority
91 }
92
93 if eisenhowerArg, exists := arguments["eisenhower"]; exists && eisenhowerArg != nil {
94 eisenhowerStr, ok := eisenhowerArg.(string)
95 if !ok {
96 return reportMCPError("Invalid type for 'eisenhower' argument: expected string.")
97 }
98 eisenhowerMap := map[string]int{
99 "uncategorised": 0,
100 "both urgent and important": 1,
101 "urgent, but not important": 2,
102 "important, but not urgent": 3,
103 "neither urgent nor important": 4,
104 }
105 translatedEisenhower, isValid := eisenhowerMap[strings.ToLower(eisenhowerStr)]
106 if !isValid {
107 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))
108 }
109 task.Eisenhower = &translatedEisenhower
110 }
111
112 if motivationVal, exists := arguments["motivation"]; exists && motivationVal != nil {
113 motivation, ok := motivationVal.(string)
114 if !ok {
115 return reportMCPError("'motivation' must be a string")
116 }
117 if motivation != "" {
118 validMotivations := map[string]bool{"must": true, "should": true, "want": true}
119 if !validMotivations[motivation] {
120 return reportMCPError("'motivation' must be one of 'must', 'should', or 'want'")
121 }
122 task.Motivation = &motivation
123 }
124 }
125
126 if statusVal, exists := arguments["status"]; exists && statusVal != nil {
127 status, ok := statusVal.(string)
128 if !ok {
129 return reportMCPError("'status' must be a string")
130 }
131 if status != "" {
132 validStatus := map[string]bool{"later": true, "next": true, "started": true, "waiting": true, "completed": true}
133 if !validStatus[status] {
134 return reportMCPError("'status' must be one of 'later', 'next', 'started', 'waiting', or 'completed'")
135 }
136 task.Status = &status
137 }
138 }
139
140 if estimateArg, exists := arguments["estimate"]; exists && estimateArg != nil {
141 estimateVal, ok := estimateArg.(float64)
142 if !ok {
143 return reportMCPError("Invalid type for 'estimate' argument: expected number.")
144 }
145 estimate := int(estimateVal)
146 if estimate < 0 || estimate > 720 {
147 return reportMCPError("'estimate' must be between 0 and 720 minutes")
148 }
149 task.Estimate = &estimate
150 }
151
152 if scheduledOnArg, exists := arguments["scheduled_on"]; exists {
153 scheduledOnStr, ok := scheduledOnArg.(string)
154 if !ok {
155 return reportMCPError("Invalid type for scheduled_on argument: expected string.")
156 }
157 if scheduledOnStr != "" {
158 if _, err := time.Parse(time.RFC3339, scheduledOnStr); err != nil {
159 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))
160 }
161 task.ScheduledOn = &scheduledOnStr
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 {
372 if scheduledOnStr != "" {
373 if _, err := time.Parse(time.RFC3339, scheduledOnStr); err != nil {
374 return reportMCPError(fmt.Sprintf("Invalid format for scheduled_on: '%s'. Must be RFC3339. Use get_timestamp tool.", scheduledOnStr))
375 }
376 }
377 updatePayload.ScheduledOn = &scheduledOnStr
378 }
379 }
380
381 client := lunatask.NewClient(h.config.AccessToken)
382 response, err := client.UpdateTask(ctx, taskID, &updatePayload)
383 if err != nil {
384 return reportMCPError(fmt.Sprintf("Failed to update task: %v", err))
385 }
386
387 return &mcp.CallToolResult{
388 Content: []mcp.Content{
389 mcp.TextContent{
390 Type: "text",
391 Text: fmt.Sprintf("Task updated successfully. ID: %s", response.ID),
392 },
393 },
394 }, nil
395}
396
397// HandleDeleteTask handles the delete_task tool call.
398func (h *Handlers) HandleDeleteTask(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
399 taskID, ok := request.Params.Arguments["task_id"].(string)
400 if !ok || taskID == "" {
401 return reportMCPError("Missing or invalid required argument: task_id")
402 }
403
404 client := lunatask.NewClient(h.config.AccessToken)
405 _, err := client.DeleteTask(ctx, taskID)
406 if err != nil {
407 return reportMCPError(fmt.Sprintf("Failed to delete task: %v", err))
408 }
409
410 return &mcp.CallToolResult{
411 Content: []mcp.Content{
412 mcp.TextContent{
413 Type: "text",
414 Text: "Task deleted successfully.",
415 },
416 },
417 }, nil
418}