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 if motivationVal, exists := arguments["motivation"]; exists && motivationVal != nil {
76 if motivation, ok := motivationVal.(string); ok && motivation != "" {
77 validMotivations := map[string]bool{"must": true, "should": true, "want": true}
78 if !validMotivations[motivation] {
79 return reportMCPError("'motivation' must be one of 'must', 'should', or 'want'")
80 }
81 } else if ok {
82 // empty string is allowed
83 } else {
84 return reportMCPError("'motivation' must be a string")
85 }
86 }
87
88 if statusVal, exists := arguments["status"]; exists && statusVal != nil {
89 if status, ok := statusVal.(string); ok && status != "" {
90 validStatus := map[string]bool{"later": true, "next": true, "started": true, "waiting": true, "completed": true}
91 if !validStatus[status] {
92 return reportMCPError("'status' must be one of 'later', 'next', 'started', 'waiting', or 'completed'")
93 }
94 } else if ok {
95 // empty string is allowed
96 } else {
97 return reportMCPError("'status' must be a string")
98 }
99 }
100
101 if scheduledOnArg, exists := arguments["scheduled_on"]; exists {
102 if scheduledOnStr, ok := scheduledOnArg.(string); ok && scheduledOnStr != "" {
103 if _, err := time.Parse(time.RFC3339, scheduledOnStr); err != nil {
104 return reportMCPError(fmt.Sprintf("Invalid format for scheduled_on: '%s'. Must be RFC3339 timestamp (e.g., YYYY-MM-DDTHH:MM:SSZ). Use get_task_timestamp tool first.", scheduledOnStr))
105 }
106 } else if !ok {
107 return reportMCPError("Invalid type for scheduled_on argument: expected string.")
108 }
109 }
110
111 client := lunatask.NewClient(h.config.AccessToken)
112 var task lunatask.CreateTaskRequest
113 argBytes, err := json.Marshal(arguments)
114 if err != nil {
115 return reportMCPError(fmt.Sprintf("Failed to process arguments: %v", err))
116 }
117 if err := json.Unmarshal(argBytes, &task); err != nil {
118 return reportMCPError(fmt.Sprintf("Failed to parse arguments: %v", err))
119 }
120
121 response, err := client.CreateTask(ctx, &task)
122 if err != nil {
123 return reportMCPError(fmt.Sprintf("%v", err))
124 }
125
126 if response == nil {
127 return &mcp.CallToolResult{
128 Content: []mcp.Content{
129 mcp.TextContent{
130 Type: "text",
131 Text: "Task already exists (not an error).",
132 },
133 },
134 }, nil
135 }
136
137 return &mcp.CallToolResult{
138 Content: []mcp.Content{
139 mcp.TextContent{
140 Type: "text",
141 Text: fmt.Sprintf("Task created successfully with ID: %s", response.Task.ID),
142 },
143 },
144 }, nil
145}
146
147// HandleUpdateTask handles the update_task tool call.
148func (h *Handlers) HandleUpdateTask(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
149 arguments := request.Params.Arguments
150
151 taskID, ok := arguments["task_id"].(string)
152 if !ok || taskID == "" {
153 return reportMCPError("Missing or invalid required argument: task_id")
154 }
155
156 if _, err := LoadLocation(h.config.Timezone); err != nil {
157 return reportMCPError(err.Error())
158 }
159
160 updatePayload := lunatask.CreateTaskRequest{}
161
162 var specifiedAreaProvider AreaProvider
163 areaIDProvided := false
164
165 if areaIDArg, exists := arguments["area_id"]; exists {
166 if areaIDStr, ok := areaIDArg.(string); ok && areaIDStr != "" {
167 updatePayload.AreaID = areaIDStr
168 areaIDProvided = true
169 found := false
170 for _, ap := range h.config.Areas {
171 if ap.GetID() == areaIDStr {
172 specifiedAreaProvider = ap
173 found = true
174 break
175 }
176 }
177 if !found {
178 return reportMCPError(fmt.Sprintf("Area not found for given area_id: %s", areaIDStr))
179 }
180 } else if !ok && areaIDArg != nil {
181 return reportMCPError("Invalid type for area_id argument: expected string.")
182 }
183 }
184
185 if goalIDArg, exists := arguments["goal_id"]; exists {
186 if goalIDStr, ok := goalIDArg.(string); ok && goalIDStr != "" {
187 updatePayload.GoalID = goalIDStr
188 if specifiedAreaProvider != nil {
189 foundGoal := false
190 for _, goal := range specifiedAreaProvider.GetGoals() {
191 if goal.GetID() == goalIDStr {
192 foundGoal = true
193 break
194 }
195 }
196 if !foundGoal {
197 return reportMCPError(fmt.Sprintf("Goal not found in specified area '%s' for given goal_id: %s", specifiedAreaProvider.GetName(), goalIDStr))
198 }
199 } else if areaIDProvided {
200 return reportMCPError("Internal error: area_id provided but area details not loaded for goal validation.")
201 }
202 } else if !ok && goalIDArg != nil {
203 return reportMCPError("Invalid type for goal_id argument: expected string.")
204 }
205 }
206
207 nameArg := arguments["name"]
208 if nameStr, ok := nameArg.(string); ok {
209 updatePayload.Name = nameStr
210 } else {
211 return reportMCPError("Invalid type for name argument: expected string.")
212 }
213
214 if noteArg, exists := arguments["note"]; exists {
215 if noteStr, ok := noteArg.(string); ok {
216 updatePayload.Note = noteStr
217 } else if !ok && noteArg != nil {
218 return reportMCPError("Invalid type for note argument: expected string.")
219 }
220 }
221
222 if estimateArg, exists := arguments["estimate"]; exists && estimateArg != nil {
223 if estimateVal, ok := estimateArg.(float64); ok {
224 updatePayload.Estimate = int(estimateVal)
225 } else {
226 return reportMCPError("Invalid type for estimate argument: expected number.")
227 }
228 }
229
230 if priorityArg, exists := arguments["priority"]; exists && priorityArg != nil {
231 priorityStr, ok := priorityArg.(string)
232 if !ok {
233 return reportMCPError("Invalid type for 'priority' argument: expected string.")
234 }
235 priorityMap := map[string]int{
236 "lowest": -2,
237 "low": -1,
238 "neutral": 0,
239 "high": 1,
240 "highest": 2,
241 }
242 translatedPriority, isValid := priorityMap[strings.ToLower(priorityStr)]
243 if !isValid {
244 return reportMCPError(fmt.Sprintf("Invalid 'priority' value: '%s'. Must be one of 'lowest', 'low', 'neutral', 'high', 'highest'.", priorityStr))
245 }
246 updatePayload.Priority = translatedPriority
247 }
248
249 if motivationArg, exists := arguments["motivation"]; exists {
250 if motivationStr, ok := motivationArg.(string); ok {
251 if motivationStr != "" {
252 validMotivations := map[string]bool{"must": true, "should": true, "want": true}
253 if !validMotivations[motivationStr] {
254 return reportMCPError("'motivation' must be one of 'must', 'should', or 'want', or empty to clear.")
255 }
256 }
257 updatePayload.Motivation = motivationStr
258 } else if !ok && motivationArg != nil {
259 return reportMCPError("Invalid type for motivation argument: expected string.")
260 }
261 }
262
263 if statusArg, exists := arguments["status"]; exists {
264 if statusStr, ok := statusArg.(string); ok {
265 if statusStr != "" {
266 validStatus := map[string]bool{"later": true, "next": true, "started": true, "waiting": true, "completed": true}
267 if !validStatus[statusStr] {
268 return reportMCPError("'status' must be one of 'later', 'next', 'started', 'waiting', 'completed', or empty.")
269 }
270 }
271 updatePayload.Status = statusStr
272 } else if !ok && statusArg != nil {
273 return reportMCPError("Invalid type for status argument: expected string.")
274 }
275 }
276
277 if scheduledOnArg, exists := arguments["scheduled_on"]; exists {
278 if scheduledOnStr, ok := scheduledOnArg.(string); ok {
279 if scheduledOnStr != "" {
280 if _, err := time.Parse(time.RFC3339, scheduledOnStr); err != nil {
281 return reportMCPError(fmt.Sprintf("Invalid format for scheduled_on: '%s'. Must be RFC3339. Use get_task_timestamp tool.", scheduledOnStr))
282 }
283 }
284 updatePayload.ScheduledOn = scheduledOnStr
285 } else if !ok && scheduledOnArg != nil {
286 return reportMCPError("Invalid type for scheduled_on argument: expected string.")
287 }
288 }
289
290 client := lunatask.NewClient(h.config.AccessToken)
291 response, err := client.UpdateTask(ctx, taskID, &updatePayload)
292 if err != nil {
293 return reportMCPError(fmt.Sprintf("Failed to update task: %v", err))
294 }
295
296 return &mcp.CallToolResult{
297 Content: []mcp.Content{
298 mcp.TextContent{
299 Type: "text",
300 Text: fmt.Sprintf("Task updated successfully. ID: %s", response.Task.ID),
301 },
302 },
303 }, nil
304}
305
306// HandleDeleteTask handles the delete_task tool call.
307func (h *Handlers) HandleDeleteTask(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
308 taskID, ok := request.Params.Arguments["task_id"].(string)
309 if !ok || taskID == "" {
310 return reportMCPError("Missing or invalid required argument: task_id")
311 }
312
313 client := lunatask.NewClient(h.config.AccessToken)
314 _, err := client.DeleteTask(ctx, taskID)
315 if err != nil {
316 return reportMCPError(fmt.Sprintf("Failed to delete task: %v", err))
317 }
318
319 return &mcp.CallToolResult{
320 Content: []mcp.Content{
321 mcp.TextContent{
322 Type: "text",
323 Text: "Task deleted successfully.",
324 },
325 },
326 }, nil
327}