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	"time"
 12
 13	"git.sr.ht/~amolith/lunatask-mcp-server/lunatask"
 14	"github.com/mark3labs/mcp-go/mcp"
 15)
 16
 17// HandleCreateTask handles the create_task tool call.
 18func (h *Handlers) HandleCreateTask(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
 19	arguments := request.Params.Arguments
 20
 21	if _, err := LoadLocation(h.config.Timezone); err != nil {
 22		return reportMCPError(err.Error())
 23	}
 24
 25	areaID, ok := arguments["area_id"].(string)
 26	if !ok || areaID == "" {
 27		return reportMCPError("Missing or invalid required argument: area_id")
 28	}
 29
 30	var areaFoundProvider AreaProvider
 31	for _, ap := range h.config.Areas {
 32		if ap.GetID() == areaID {
 33			areaFoundProvider = ap
 34			break
 35		}
 36	}
 37	if areaFoundProvider == nil {
 38		return reportMCPError("Area not found for given area_id")
 39	}
 40
 41	var goalID *string
 42	if goalIDStr, exists := arguments["goal_id"].(string); exists && goalIDStr != "" {
 43		found := false
 44		for _, goal := range areaFoundProvider.GetGoals() {
 45			if goal.GetID() == goalIDStr {
 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		goalID = &goalIDStr
 54	}
 55
 56	name, ok := arguments["name"].(string)
 57	if !ok || name == "" {
 58		return reportMCPError("Missing or invalid required argument: name")
 59	}
 60	if len(name) > 100 {
 61		return reportMCPError("'name' must be 100 characters or fewer")
 62	}
 63
 64	task := lunatask.CreateTaskRequest{
 65		Name:   name,
 66		AreaID: &areaID,
 67		GoalID: goalID,
 68	}
 69
 70	if noteVal, exists := arguments["note"].(string); exists {
 71		task.Note = &noteVal
 72	}
 73
 74	if priorityArg, exists := arguments["priority"]; exists && priorityArg != nil {
 75		priorityStr, ok := priorityArg.(string)
 76		if !ok {
 77			return reportMCPError("Invalid type for 'priority' argument: expected string.")
 78		}
 79		priorityMap := map[string]int{
 80			"lowest":  -2,
 81			"low":     -1,
 82			"neutral": 0,
 83			"high":    1,
 84			"highest": 2,
 85		}
 86		translatedPriority, isValid := priorityMap[strings.ToLower(priorityStr)]
 87		if !isValid {
 88			return reportMCPError(fmt.Sprintf("Invalid 'priority' value: '%s'. Must be one of 'lowest', 'low', 'neutral', 'high', 'highest'.", priorityStr))
 89		}
 90		task.Priority = &translatedPriority
 91	}
 92
 93	if eisenhowerArg, exists := arguments["eisenhower"]; exists && eisenhowerArg != nil {
 94		eisenhowerStr, ok := eisenhowerArg.(string)
 95		if !ok {
 96			return reportMCPError("Invalid type for 'eisenhower' argument: expected string.")
 97		}
 98		eisenhowerMap := map[string]int{
 99			"uncategorised":                0,
100			"both urgent and important":    1,
101			"urgent, but not important":    2,
102			"important, but not urgent":    3,
103			"neither urgent nor important": 4,
104		}
105		translatedEisenhower, isValid := eisenhowerMap[strings.ToLower(eisenhowerStr)]
106		if !isValid {
107			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))
108		}
109		task.Eisenhower = &translatedEisenhower
110	}
111
112	if motivationVal, exists := arguments["motivation"]; exists && motivationVal != nil {
113		motivation, ok := motivationVal.(string)
114		if !ok {
115			return reportMCPError("'motivation' must be a string")
116		}
117		if motivation != "" {
118			validMotivations := map[string]bool{"must": true, "should": true, "want": true}
119			if !validMotivations[motivation] {
120				return reportMCPError("'motivation' must be one of 'must', 'should', or 'want'")
121			}
122			task.Motivation = &motivation
123		}
124	}
125
126	if statusVal, exists := arguments["status"]; exists && statusVal != nil {
127		status, ok := statusVal.(string)
128		if !ok {
129			return reportMCPError("'status' must be a string")
130		}
131		if status != "" {
132			validStatus := map[string]bool{"later": true, "next": true, "started": true, "waiting": true, "completed": true}
133			if !validStatus[status] {
134				return reportMCPError("'status' must be one of 'later', 'next', 'started', 'waiting', or 'completed'")
135			}
136			task.Status = &status
137		}
138	}
139
140	if estimateArg, exists := arguments["estimate"]; exists && estimateArg != nil {
141		estimateVal, ok := estimateArg.(float64)
142		if !ok {
143			return reportMCPError("Invalid type for 'estimate' argument: expected number.")
144		}
145		estimate := int(estimateVal)
146		if estimate < 0 || estimate > 720 {
147			return reportMCPError("'estimate' must be between 0 and 720 minutes")
148		}
149		task.Estimate = &estimate
150	}
151
152	if scheduledOnArg, exists := arguments["scheduled_on"]; exists {
153		scheduledOnStr, ok := scheduledOnArg.(string)
154		if !ok {
155			return reportMCPError("Invalid type for scheduled_on argument: expected string.")
156		}
157		if scheduledOnStr != "" {
158			if _, err := time.Parse(time.RFC3339, scheduledOnStr); err != nil {
159				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))
160			}
161			task.ScheduledOn = &scheduledOnStr
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 {
372			if scheduledOnStr != "" {
373				if _, err := time.Parse(time.RFC3339, scheduledOnStr); err != nil {
374					return reportMCPError(fmt.Sprintf("Invalid format for scheduled_on: '%s'. Must be RFC3339. Use get_timestamp tool.", scheduledOnStr))
375				}
376			}
377			updatePayload.ScheduledOn = &scheduledOnStr
378		}
379	}
380
381	client := lunatask.NewClient(h.config.AccessToken)
382	response, err := client.UpdateTask(ctx, taskID, &updatePayload)
383	if err != nil {
384		return reportMCPError(fmt.Sprintf("Failed to update task: %v", err))
385	}
386
387	return &mcp.CallToolResult{
388		Content: []mcp.Content{
389			mcp.TextContent{
390				Type: "text",
391				Text: fmt.Sprintf("Task updated successfully. ID: %s", response.ID),
392			},
393		},
394	}, nil
395}
396
397// HandleDeleteTask handles the delete_task tool call.
398func (h *Handlers) HandleDeleteTask(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
399	taskID, ok := request.Params.Arguments["task_id"].(string)
400	if !ok || taskID == "" {
401		return reportMCPError("Missing or invalid required argument: task_id")
402	}
403
404	client := lunatask.NewClient(h.config.AccessToken)
405	_, err := client.DeleteTask(ctx, taskID)
406	if err != nil {
407		return reportMCPError(fmt.Sprintf("Failed to delete task: %v", err))
408	}
409
410	return &mcp.CallToolResult{
411		Content: []mcp.Content{
412			mcp.TextContent{
413				Type: "text",
414				Text: "Task deleted successfully.",
415			},
416		},
417	}, nil
418}