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	"fmt"
 10	"strings"
 11
 12	"git.sr.ht/~amolith/lunatask-mcp-server/lunatask"
 13	"github.com/mark3labs/mcp-go/mcp"
 14)
 15
 16// HandleCreateTask handles the create_task tool call.
 17func (h *Handlers) HandleCreateTask(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
 18	arguments := request.Params.Arguments
 19
 20	if _, err := LoadLocation(h.config.Timezone); err != nil {
 21		return reportMCPError(err.Error())
 22	}
 23
 24	areaID, ok := arguments["area_id"].(string)
 25	if !ok || areaID == "" {
 26		return reportMCPError("Missing or invalid required argument: area_id")
 27	}
 28
 29	var areaFoundProvider AreaProvider
 30	for _, ap := range h.config.Areas {
 31		if ap.GetID() == areaID {
 32			areaFoundProvider = ap
 33			break
 34		}
 35	}
 36	if areaFoundProvider == nil {
 37		return reportMCPError("Area not found for given area_id")
 38	}
 39
 40	var goalID *string
 41	if goalIDStr, exists := arguments["goal_id"].(string); exists && goalIDStr != "" {
 42		found := false
 43		for _, goal := range areaFoundProvider.GetGoals() {
 44			if goal.GetID() == goalIDStr {
 45				found = true
 46				break
 47			}
 48		}
 49		if !found {
 50			return reportMCPError("Goal not found in specified area for given goal_id")
 51		}
 52		goalID = &goalIDStr
 53	}
 54
 55	name, ok := arguments["name"].(string)
 56	if !ok || name == "" {
 57		return reportMCPError("Missing or invalid required argument: name")
 58	}
 59	if len(name) > 100 {
 60		return reportMCPError("'name' must be 100 characters or fewer")
 61	}
 62
 63	task := lunatask.CreateTaskRequest{
 64		Name:   name,
 65		AreaID: &areaID,
 66		GoalID: goalID,
 67	}
 68
 69	if noteVal, exists := arguments["note"].(string); exists {
 70		task.Note = &noteVal
 71	}
 72
 73	if priorityArg, exists := arguments["priority"]; exists && priorityArg != nil {
 74		priorityStr, ok := priorityArg.(string)
 75		if !ok {
 76			return reportMCPError("Invalid type for 'priority' argument: expected string.")
 77		}
 78		priorityMap := map[string]int{
 79			"lowest":  -2,
 80			"low":     -1,
 81			"neutral": 0,
 82			"high":    1,
 83			"highest": 2,
 84		}
 85		translatedPriority, isValid := priorityMap[strings.ToLower(priorityStr)]
 86		if !isValid {
 87			return reportMCPError(fmt.Sprintf("Invalid 'priority' value: '%s'. Must be one of 'lowest', 'low', 'neutral', 'high', 'highest'.", priorityStr))
 88		}
 89		task.Priority = &translatedPriority
 90	}
 91
 92	if eisenhowerArg, exists := arguments["eisenhower"]; exists && eisenhowerArg != nil {
 93		eisenhowerStr, ok := eisenhowerArg.(string)
 94		if !ok {
 95			return reportMCPError("Invalid type for 'eisenhower' argument: expected string.")
 96		}
 97		eisenhowerMap := map[string]int{
 98			"uncategorised":                0,
 99			"both urgent and important":    1,
100			"urgent, but not important":    2,
101			"important, but not urgent":    3,
102			"neither urgent nor important": 4,
103		}
104		translatedEisenhower, isValid := eisenhowerMap[strings.ToLower(eisenhowerStr)]
105		if !isValid {
106			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))
107		}
108		task.Eisenhower = &translatedEisenhower
109	}
110
111	if motivationVal, exists := arguments["motivation"]; exists && motivationVal != nil {
112		motivation, ok := motivationVal.(string)
113		if !ok {
114			return reportMCPError("'motivation' must be a string")
115		}
116		if motivation != "" {
117			validMotivations := map[string]bool{"must": true, "should": true, "want": true}
118			if !validMotivations[motivation] {
119				return reportMCPError("'motivation' must be one of 'must', 'should', or 'want'")
120			}
121			task.Motivation = &motivation
122		}
123	}
124
125	if statusVal, exists := arguments["status"]; exists && statusVal != nil {
126		status, ok := statusVal.(string)
127		if !ok {
128			return reportMCPError("'status' must be a string")
129		}
130		if status != "" {
131			validStatus := map[string]bool{"later": true, "next": true, "started": true, "waiting": true, "completed": true}
132			if !validStatus[status] {
133				return reportMCPError("'status' must be one of 'later', 'next', 'started', 'waiting', or 'completed'")
134			}
135			task.Status = &status
136		}
137	}
138
139	if estimateArg, exists := arguments["estimate"]; exists && estimateArg != nil {
140		estimateVal, ok := estimateArg.(float64)
141		if !ok {
142			return reportMCPError("Invalid type for 'estimate' argument: expected number.")
143		}
144		estimate := int(estimateVal)
145		if estimate < 0 || estimate > 720 {
146			return reportMCPError("'estimate' must be between 0 and 720 minutes")
147		}
148		task.Estimate = &estimate
149	}
150
151	if scheduledOnArg, exists := arguments["scheduled_on"]; exists {
152		scheduledOnStr, ok := scheduledOnArg.(string)
153		if !ok {
154			return reportMCPError("Invalid type for scheduled_on argument: expected string.")
155		}
156		if scheduledOnStr != "" {
157			date, err := lunatask.ParseDate(scheduledOnStr)
158			if err != nil {
159				return reportMCPError(fmt.Sprintf("Invalid format for scheduled_on: '%s'. Must be YYYY-MM-DD.", scheduledOnStr))
160			}
161			task.ScheduledOn = &date
162		}
163	}
164
165	if sourceArg, exists := arguments["source"].(string); exists && sourceArg != "" {
166		task.Source = &sourceArg
167	}
168
169	if sourceIDArg, exists := arguments["source_id"].(string); exists && sourceIDArg != "" {
170		task.SourceID = &sourceIDArg
171	}
172
173	client := lunatask.NewClient(h.config.AccessToken)
174	response, err := client.CreateTask(ctx, &task)
175	if err != nil {
176		return reportMCPError(fmt.Sprintf("%v", err))
177	}
178
179	if response == nil {
180		return &mcp.CallToolResult{
181			Content: []mcp.Content{
182				mcp.TextContent{
183					Type: "text",
184					Text: "Task already exists (not an error).",
185				},
186			},
187		}, nil
188	}
189
190	return &mcp.CallToolResult{
191		Content: []mcp.Content{
192			mcp.TextContent{
193				Type: "text",
194				Text: fmt.Sprintf("Task created successfully with ID: %s", response.ID),
195			},
196		},
197	}, nil
198}
199
200// HandleUpdateTask handles the update_task tool call.
201func (h *Handlers) HandleUpdateTask(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
202	arguments := request.Params.Arguments
203
204	taskID, ok := arguments["task_id"].(string)
205	if !ok || taskID == "" {
206		return reportMCPError("Missing or invalid required argument: task_id")
207	}
208
209	if _, err := LoadLocation(h.config.Timezone); err != nil {
210		return reportMCPError(err.Error())
211	}
212
213	updatePayload := lunatask.UpdateTaskRequest{}
214
215	var specifiedAreaProvider AreaProvider
216	areaIDProvided := false
217
218	if areaIDArg, exists := arguments["area_id"]; exists {
219		areaIDStr, ok := areaIDArg.(string)
220		if !ok && areaIDArg != nil {
221			return reportMCPError("Invalid type for area_id argument: expected string.")
222		}
223		if ok && areaIDStr != "" {
224			updatePayload.AreaID = &areaIDStr
225			areaIDProvided = true
226			found := false
227			for _, ap := range h.config.Areas {
228				if ap.GetID() == areaIDStr {
229					specifiedAreaProvider = ap
230					found = true
231					break
232				}
233			}
234			if !found {
235				return reportMCPError(fmt.Sprintf("Area not found for given area_id: %s", areaIDStr))
236			}
237		}
238	}
239
240	if goalIDArg, exists := arguments["goal_id"]; exists {
241		goalIDStr, ok := goalIDArg.(string)
242		if !ok && goalIDArg != nil {
243			return reportMCPError("Invalid type for goal_id argument: expected string.")
244		}
245		if ok && goalIDStr != "" {
246			updatePayload.GoalID = &goalIDStr
247			if specifiedAreaProvider != nil {
248				foundGoal := false
249				for _, goal := range specifiedAreaProvider.GetGoals() {
250					if goal.GetID() == goalIDStr {
251						foundGoal = true
252						break
253					}
254				}
255				if !foundGoal {
256					return reportMCPError(fmt.Sprintf("Goal not found in specified area '%s' for given goal_id: %s", specifiedAreaProvider.GetName(), goalIDStr))
257				}
258			} else if areaIDProvided {
259				return reportMCPError("Internal error: area_id provided but area details not loaded for goal validation.")
260			}
261		}
262	}
263
264	nameArg := arguments["name"]
265	if nameStr, ok := nameArg.(string); ok {
266		if len(nameStr) > 100 {
267			return reportMCPError("'name' must be 100 characters or fewer")
268		}
269		updatePayload.Name = &nameStr
270	} else {
271		return reportMCPError("Invalid type for name argument: expected string.")
272	}
273
274	if noteArg, exists := arguments["note"]; exists {
275		noteStr, ok := noteArg.(string)
276		if !ok && noteArg != nil {
277			return reportMCPError("Invalid type for note argument: expected string.")
278		}
279		if ok {
280			updatePayload.Note = &noteStr
281		}
282	}
283
284	if estimateArg, exists := arguments["estimate"]; exists && estimateArg != nil {
285		estimateVal, ok := estimateArg.(float64)
286		if !ok {
287			return reportMCPError("Invalid type for estimate argument: expected number.")
288		}
289		estimate := int(estimateVal)
290		if estimate < 0 || estimate > 720 {
291			return reportMCPError("'estimate' must be between 0 and 720 minutes")
292		}
293		updatePayload.Estimate = &estimate
294	}
295
296	if priorityArg, exists := arguments["priority"]; exists && priorityArg != nil {
297		priorityStr, ok := priorityArg.(string)
298		if !ok {
299			return reportMCPError("Invalid type for 'priority' argument: expected string.")
300		}
301		priorityMap := map[string]int{
302			"lowest":  -2,
303			"low":     -1,
304			"neutral": 0,
305			"high":    1,
306			"highest": 2,
307		}
308		translatedPriority, isValid := priorityMap[strings.ToLower(priorityStr)]
309		if !isValid {
310			return reportMCPError(fmt.Sprintf("Invalid 'priority' value: '%s'. Must be one of 'lowest', 'low', 'neutral', 'high', 'highest'.", priorityStr))
311		}
312		updatePayload.Priority = &translatedPriority
313	}
314
315	if eisenhowerArg, exists := arguments["eisenhower"]; exists && eisenhowerArg != nil {
316		eisenhowerStr, ok := eisenhowerArg.(string)
317		if !ok {
318			return reportMCPError("Invalid type for 'eisenhower' argument: expected string.")
319		}
320		eisenhowerMap := map[string]int{
321			"uncategorised":                0,
322			"both urgent and important":    1,
323			"urgent, but not important":    2,
324			"important, but not urgent":    3,
325			"neither urgent nor important": 4,
326		}
327		translatedEisenhower, isValid := eisenhowerMap[strings.ToLower(eisenhowerStr)]
328		if !isValid {
329			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))
330		}
331		updatePayload.Eisenhower = &translatedEisenhower
332	}
333
334	if motivationArg, exists := arguments["motivation"]; exists {
335		motivationStr, ok := motivationArg.(string)
336		if !ok && motivationArg != nil {
337			return reportMCPError("Invalid type for motivation argument: expected string.")
338		}
339		if ok {
340			if motivationStr != "" {
341				validMotivations := map[string]bool{"must": true, "should": true, "want": true}
342				if !validMotivations[motivationStr] {
343					return reportMCPError("'motivation' must be one of 'must', 'should', or 'want', or empty to clear.")
344				}
345			}
346			updatePayload.Motivation = &motivationStr
347		}
348	}
349
350	if statusArg, exists := arguments["status"]; exists {
351		statusStr, ok := statusArg.(string)
352		if !ok && statusArg != nil {
353			return reportMCPError("Invalid type for status argument: expected string.")
354		}
355		if ok {
356			if statusStr != "" {
357				validStatus := map[string]bool{"later": true, "next": true, "started": true, "waiting": true, "completed": true}
358				if !validStatus[statusStr] {
359					return reportMCPError("'status' must be one of 'later', 'next', 'started', 'waiting', 'completed', or empty.")
360				}
361			}
362			updatePayload.Status = &statusStr
363		}
364	}
365
366	if scheduledOnArg, exists := arguments["scheduled_on"]; exists {
367		scheduledOnStr, ok := scheduledOnArg.(string)
368		if !ok && scheduledOnArg != nil {
369			return reportMCPError("Invalid type for scheduled_on argument: expected string.")
370		}
371		if ok && scheduledOnStr != "" {
372			date, err := lunatask.ParseDate(scheduledOnStr)
373			if err != nil {
374				return reportMCPError(fmt.Sprintf("Invalid format for scheduled_on: '%s'. Must be YYYY-MM-DD.", scheduledOnStr))
375			}
376			updatePayload.ScheduledOn = &date
377		}
378	}
379
380	client := lunatask.NewClient(h.config.AccessToken)
381	response, err := client.UpdateTask(ctx, taskID, &updatePayload)
382	if err != nil {
383		return reportMCPError(fmt.Sprintf("Failed to update task: %v", err))
384	}
385
386	return &mcp.CallToolResult{
387		Content: []mcp.Content{
388			mcp.TextContent{
389				Type: "text",
390				Text: fmt.Sprintf("Task updated successfully. ID: %s", response.ID),
391			},
392		},
393	}, nil
394}
395
396// HandleDeleteTask handles the delete_task tool call.
397func (h *Handlers) HandleDeleteTask(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
398	taskID, ok := request.Params.Arguments["task_id"].(string)
399	if !ok || taskID == "" {
400		return reportMCPError("Missing or invalid required argument: task_id")
401	}
402
403	client := lunatask.NewClient(h.config.AccessToken)
404	_, err := client.DeleteTask(ctx, taskID)
405	if err != nil {
406		return reportMCPError(fmt.Sprintf("Failed to delete task: %v", err))
407	}
408
409	return &mcp.CallToolResult{
410		Content: []mcp.Content{
411			mcp.TextContent{
412				Type: "text",
413				Text: "Task deleted successfully.",
414			},
415		},
416	}, nil
417}