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