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