tasks.go

  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_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_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}