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