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	}
204
205	if goalIDArg, exists := arguments["goal_id"]; exists {
206		if goalIDStr, ok := goalIDArg.(string); ok && goalIDStr != "" {
207			updatePayload.GoalID = goalIDStr
208			if specifiedAreaProvider != nil {
209				foundGoal := false
210				for _, goal := range specifiedAreaProvider.GetGoals() {
211					if goal.GetID() == goalIDStr {
212						foundGoal = true
213						break
214					}
215				}
216				if !foundGoal {
217					return reportMCPError(fmt.Sprintf("Goal not found in specified area '%s' for given goal_id: %s", specifiedAreaProvider.GetName(), goalIDStr))
218				}
219			} else if areaIDProvided {
220				return reportMCPError("Internal error: area_id provided but area details not loaded for goal validation.")
221			}
222		} else if !ok && goalIDArg != nil {
223			return reportMCPError("Invalid type for goal_id argument: expected string.")
224		}
225	}
226
227	nameArg := arguments["name"]
228	if nameStr, ok := nameArg.(string); ok {
229		updatePayload.Name = nameStr
230	} else {
231		return reportMCPError("Invalid type for name argument: expected string.")
232	}
233
234	if noteArg, exists := arguments["note"]; exists {
235		if noteStr, ok := noteArg.(string); ok {
236			updatePayload.Note = noteStr
237		} else if !ok && noteArg != nil {
238			return reportMCPError("Invalid type for note argument: expected string.")
239		}
240	}
241
242	if estimateArg, exists := arguments["estimate"]; exists && estimateArg != nil {
243		if estimateVal, ok := estimateArg.(float64); ok {
244			updatePayload.Estimate = int(estimateVal)
245		} else {
246			return reportMCPError("Invalid type for estimate argument: expected number.")
247		}
248	}
249
250	if priorityArg, exists := arguments["priority"]; exists && priorityArg != nil {
251		priorityStr, ok := priorityArg.(string)
252		if !ok {
253			return reportMCPError("Invalid type for 'priority' argument: expected string.")
254		}
255		priorityMap := map[string]int{
256			"lowest":  -2,
257			"low":     -1,
258			"neutral": 0,
259			"high":    1,
260			"highest": 2,
261		}
262		translatedPriority, isValid := priorityMap[strings.ToLower(priorityStr)]
263		if !isValid {
264			return reportMCPError(fmt.Sprintf("Invalid 'priority' value: '%s'. Must be one of 'lowest', 'low', 'neutral', 'high', 'highest'.", priorityStr))
265		}
266		updatePayload.Priority = translatedPriority
267	}
268
269	if eisenhowerArg, exists := arguments["eisenhower"]; exists && eisenhowerArg != nil {
270		eisenhowerStr, ok := eisenhowerArg.(string)
271		if !ok {
272			return reportMCPError("Invalid type for 'eisenhower' argument: expected string.")
273		}
274		eisenhowerMap := map[string]int{
275			"uncategorised":                0,
276			"both urgent and important":    1,
277			"urgent, but not important":    2,
278			"important, but not urgent":    3,
279			"neither urgent nor important": 4,
280		}
281		translatedEisenhower, isValid := eisenhowerMap[strings.ToLower(eisenhowerStr)]
282		if !isValid {
283			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))
284		}
285		updatePayload.Eisenhower = translatedEisenhower
286	}
287
288	if motivationArg, exists := arguments["motivation"]; exists {
289		if motivationStr, ok := motivationArg.(string); ok {
290			if motivationStr != "" {
291				validMotivations := map[string]bool{"must": true, "should": true, "want": true}
292				if !validMotivations[motivationStr] {
293					return reportMCPError("'motivation' must be one of 'must', 'should', or 'want', or empty to clear.")
294				}
295			}
296			updatePayload.Motivation = motivationStr
297		} else if !ok && motivationArg != nil {
298			return reportMCPError("Invalid type for motivation argument: expected string.")
299		}
300	}
301
302	if statusArg, exists := arguments["status"]; exists {
303		if statusStr, ok := statusArg.(string); ok {
304			if statusStr != "" {
305				validStatus := map[string]bool{"later": true, "next": true, "started": true, "waiting": true, "completed": true}
306				if !validStatus[statusStr] {
307					return reportMCPError("'status' must be one of 'later', 'next', 'started', 'waiting', 'completed', or empty.")
308				}
309			}
310			updatePayload.Status = statusStr
311		} else if !ok && statusArg != nil {
312			return reportMCPError("Invalid type for status argument: expected string.")
313		}
314	}
315
316	if scheduledOnArg, exists := arguments["scheduled_on"]; exists {
317		if scheduledOnStr, ok := scheduledOnArg.(string); ok {
318			if scheduledOnStr != "" {
319				if _, err := time.Parse(time.RFC3339, scheduledOnStr); err != nil {
320					return reportMCPError(fmt.Sprintf("Invalid format for scheduled_on: '%s'. Must be RFC3339. Use get_timestamp tool.", scheduledOnStr))
321				}
322			}
323			updatePayload.ScheduledOn = scheduledOnStr
324		} else if !ok && scheduledOnArg != nil {
325			return reportMCPError("Invalid type for scheduled_on argument: expected string.")
326		}
327	}
328
329	client := lunatask.NewClient(h.config.AccessToken)
330	response, err := client.UpdateTask(ctx, taskID, &updatePayload)
331	if err != nil {
332		return reportMCPError(fmt.Sprintf("Failed to update task: %v", err))
333	}
334
335	return &mcp.CallToolResult{
336		Content: []mcp.Content{
337			mcp.TextContent{
338				Type: "text",
339				Text: fmt.Sprintf("Task updated successfully. ID: %s", response.Task.ID),
340			},
341		},
342	}, nil
343}
344
345// HandleDeleteTask handles the delete_task tool call.
346func (h *Handlers) HandleDeleteTask(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
347	taskID, ok := request.Params.Arguments["task_id"].(string)
348	if !ok || taskID == "" {
349		return reportMCPError("Missing or invalid required argument: task_id")
350	}
351
352	client := lunatask.NewClient(h.config.AccessToken)
353	_, err := client.DeleteTask(ctx, taskID)
354	if err != nil {
355		return reportMCPError(fmt.Sprintf("Failed to delete task: %v", err))
356	}
357
358	return &mcp.CallToolResult{
359		Content: []mcp.Content{
360			mcp.TextContent{
361				Type: "text",
362				Text: "Task deleted successfully.",
363			},
364		},
365	}, nil
366}