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// AreaProvider defines the interface for accessing area data.
 21type AreaProvider interface {
 22	GetName() string
 23	GetID() string
 24	GetGoals() []GoalProvider
 25}
 26
 27// GoalProvider defines the interface for accessing goal data.
 28type GoalProvider interface {
 29	GetName() string
 30	GetID() string
 31}
 32
 33// HabitProvider defines the interface for accessing habit data.
 34type HabitProvider interface {
 35	GetName() string
 36	GetID() string
 37}
 38
 39// HandlerConfig holds the necessary configuration for tool handlers.
 40type HandlerConfig struct {
 41	AccessToken string
 42	Timezone    string
 43	Areas       []AreaProvider
 44	Habits      []HabitProvider
 45}
 46
 47// Handlers provides methods for handling MCP tool calls.
 48type Handlers struct {
 49	config HandlerConfig
 50}
 51
 52// NewHandlers creates a new Handlers instance.
 53func NewHandlers(config HandlerConfig) *Handlers {
 54	return &Handlers{config: config}
 55}
 56
 57// reportMCPError creates an MCP error result.
 58func reportMCPError(msg string) (*mcp.CallToolResult, error) {
 59	return &mcp.CallToolResult{
 60		IsError: true,
 61		Content: []mcp.Content{mcp.TextContent{Type: "text", Text: msg}},
 62	}, nil
 63}
 64
 65// LoadLocation loads a timezone location string, returning a *time.Location or error
 66func LoadLocation(timezone string) (*time.Location, error) {
 67	if timezone == "" {
 68		return nil, fmt.Errorf("timezone is not configured; please set the 'timezone' value in your config file (e.g. 'UTC' or 'America/New_York')")
 69	}
 70	loc, err := time.LoadLocation(timezone)
 71	if err != nil {
 72		return nil, fmt.Errorf("could not load timezone '%s': %v", timezone, err)
 73	}
 74	return loc, nil
 75}
 76
 77// HandleGetTimestamp handles the get_timestamp tool call.
 78func (h *Handlers) HandleGetTimestamp(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
 79	natLangDate, ok := request.Params.Arguments["natural_language_date"].(string)
 80	if !ok || natLangDate == "" {
 81		return reportMCPError("Missing or invalid required argument: natural_language_date")
 82	}
 83	loc, err := LoadLocation(h.config.Timezone)
 84	if err != nil {
 85		return reportMCPError(err.Error())
 86	}
 87	parsedTime, err := anytime.Parse(natLangDate, time.Now().In(loc))
 88	if err != nil {
 89		return reportMCPError(fmt.Sprintf("Could not parse natural language date: %v", err))
 90	}
 91	return &mcp.CallToolResult{
 92		Content: []mcp.Content{
 93			mcp.TextContent{
 94				Type: "text",
 95				Text: parsedTime.Format(time.RFC3339),
 96			},
 97		},
 98	}, nil
 99}
100
101// HandleListAreasAndGoals handles the list_areas_and_goals tool call.
102func (h *Handlers) HandleListAreasAndGoals(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
103	var b strings.Builder
104	for _, area := range h.config.Areas {
105		fmt.Fprintf(&b, "- %s: %s\n", area.GetName(), area.GetID())
106		for _, goal := range area.GetGoals() {
107			fmt.Fprintf(&b, "  - %s: %s\n", goal.GetName(), goal.GetID())
108		}
109	}
110	return &mcp.CallToolResult{
111		Content: []mcp.Content{
112			mcp.TextContent{
113				Type: "text",
114				Text: b.String(),
115			},
116		},
117	}, nil
118}
119
120// HandleCreateTask handles the create_task tool call.
121func (h *Handlers) HandleCreateTask(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
122	arguments := request.Params.Arguments
123
124	if _, err := LoadLocation(h.config.Timezone); err != nil {
125		return reportMCPError(err.Error())
126	}
127
128	areaID, ok := arguments["area_id"].(string)
129	if !ok || areaID == "" {
130		return reportMCPError("Missing or invalid required argument: area_id")
131	}
132
133	var areaFoundProvider AreaProvider
134	for _, ap := range h.config.Areas {
135		if ap.GetID() == areaID {
136			areaFoundProvider = ap
137			break
138		}
139	}
140	if areaFoundProvider == nil {
141		return reportMCPError("Area not found for given area_id")
142	}
143
144	if goalID, exists := arguments["goal_id"].(string); exists && goalID != "" {
145		found := false
146		for _, goal := range areaFoundProvider.GetGoals() {
147			if goal.GetID() == goalID {
148				found = true
149				break
150			}
151		}
152		if !found {
153			return reportMCPError("Goal not found in specified area for given goal_id")
154		}
155	}
156
157	priorityMap := map[string]int{
158		"lowest":  -2,
159		"low":     -1,
160		"neutral": 0,
161		"high":    1,
162		"highest": 2,
163	}
164
165	if priorityArg, exists := arguments["priority"]; exists && priorityArg != nil {
166		priorityStr, ok := priorityArg.(string)
167		if !ok {
168			return reportMCPError("Invalid type for 'priority' argument: expected string.")
169		}
170		translatedPriority, isValid := priorityMap[strings.ToLower(priorityStr)]
171		if !isValid {
172			return reportMCPError(fmt.Sprintf("Invalid 'priority' value: '%s'. Must be one of 'lowest', 'low', 'neutral', 'high', 'highest'.", priorityStr))
173		}
174		arguments["priority"] = translatedPriority
175	}
176
177	if motivationVal, exists := arguments["motivation"]; exists && motivationVal != nil {
178		if motivation, ok := motivationVal.(string); ok && motivation != "" {
179			validMotivations := map[string]bool{"must": true, "should": true, "want": true}
180			if !validMotivations[motivation] {
181				return reportMCPError("'motivation' must be one of 'must', 'should', or 'want'")
182			}
183		} else if ok {
184			// empty string is allowed
185		} else {
186			return reportMCPError("'motivation' must be a string")
187		}
188	}
189
190	if statusVal, exists := arguments["status"]; exists && statusVal != nil {
191		if status, ok := statusVal.(string); ok && status != "" {
192			validStatus := map[string]bool{"later": true, "next": true, "started": true, "waiting": true, "completed": true}
193			if !validStatus[status] {
194				return reportMCPError("'status' must be one of 'later', 'next', 'started', 'waiting', or 'completed'")
195			}
196		} else if ok {
197			// empty string is allowed
198		} else {
199			return reportMCPError("'status' must be a string")
200		}
201	}
202
203	if scheduledOnArg, exists := arguments["scheduled_on"]; exists {
204		if scheduledOnStr, ok := scheduledOnArg.(string); ok && scheduledOnStr != "" {
205			if _, err := time.Parse(time.RFC3339, scheduledOnStr); err != nil {
206				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))
207			}
208		} else if !ok {
209			return reportMCPError("Invalid type for scheduled_on argument: expected string.")
210		}
211	}
212
213	client := lunatask.NewClient(h.config.AccessToken)
214	var task lunatask.CreateTaskRequest
215	argBytes, err := json.Marshal(arguments)
216	if err != nil {
217		return reportMCPError(fmt.Sprintf("Failed to process arguments: %v", err))
218	}
219	if err := json.Unmarshal(argBytes, &task); err != nil {
220		return reportMCPError(fmt.Sprintf("Failed to parse arguments: %v", err))
221	}
222
223	response, err := client.CreateTask(ctx, &task)
224	if err != nil {
225		return reportMCPError(fmt.Sprintf("%v", err))
226	}
227
228	if response == nil {
229		return &mcp.CallToolResult{
230			Content: []mcp.Content{
231				mcp.TextContent{
232					Type: "text",
233					Text: "Task already exists (not an error).",
234				},
235			},
236		}, nil
237	}
238
239	return &mcp.CallToolResult{
240		Content: []mcp.Content{
241			mcp.TextContent{
242				Type: "text",
243				Text: fmt.Sprintf("Task created successfully with ID: %s", response.Task.ID),
244			},
245		},
246	}, nil
247}
248
249// HandleUpdateTask handles the update_task tool call.
250func (h *Handlers) HandleUpdateTask(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
251	arguments := request.Params.Arguments
252
253	taskID, ok := arguments["task_id"].(string)
254	if !ok || taskID == "" {
255		return reportMCPError("Missing or invalid required argument: task_id")
256	}
257
258	if _, err := LoadLocation(h.config.Timezone); err != nil {
259		return reportMCPError(err.Error())
260	}
261
262	updatePayload := lunatask.CreateTaskRequest{}
263
264	var specifiedAreaProvider AreaProvider
265	areaIDProvided := false
266
267	if areaIDArg, exists := arguments["area_id"]; exists {
268		if areaIDStr, ok := areaIDArg.(string); ok && areaIDStr != "" {
269			updatePayload.AreaID = areaIDStr
270			areaIDProvided = true
271			found := false
272			for _, ap := range h.config.Areas {
273				if ap.GetID() == areaIDStr {
274					specifiedAreaProvider = ap
275					found = true
276					break
277				}
278			}
279			if !found {
280				return reportMCPError(fmt.Sprintf("Area not found for given area_id: %s", areaIDStr))
281			}
282		} else if !ok && areaIDArg != nil {
283			return reportMCPError("Invalid type for area_id argument: expected string.")
284		}
285	}
286
287	if goalIDArg, exists := arguments["goal_id"]; exists {
288		if goalIDStr, ok := goalIDArg.(string); ok && goalIDStr != "" {
289			updatePayload.GoalID = goalIDStr
290			if specifiedAreaProvider != nil {
291				foundGoal := false
292				for _, goal := range specifiedAreaProvider.GetGoals() {
293					if goal.GetID() == goalIDStr {
294						foundGoal = true
295						break
296					}
297				}
298				if !foundGoal {
299					return reportMCPError(fmt.Sprintf("Goal not found in specified area '%s' for given goal_id: %s", specifiedAreaProvider.GetName(), goalIDStr))
300				}
301			} else if areaIDProvided {
302				return reportMCPError("Internal error: area_id provided but area details not loaded for goal validation.")
303			}
304		} else if !ok && goalIDArg != nil {
305			return reportMCPError("Invalid type for goal_id argument: expected string.")
306		}
307	}
308
309	nameArg := arguments["name"]
310	if nameStr, ok := nameArg.(string); ok {
311		updatePayload.Name = nameStr
312	} else {
313		return reportMCPError("Invalid type for name argument: expected string.")
314	}
315
316	if noteArg, exists := arguments["note"]; exists {
317		if noteStr, ok := noteArg.(string); ok {
318			updatePayload.Note = noteStr
319		} else if !ok && noteArg != nil {
320			return reportMCPError("Invalid type for note argument: expected string.")
321		}
322	}
323
324	if estimateArg, exists := arguments["estimate"]; exists && estimateArg != nil {
325		if estimateVal, ok := estimateArg.(float64); ok {
326			updatePayload.Estimate = int(estimateVal)
327		} else {
328			return reportMCPError("Invalid type for estimate argument: expected number.")
329		}
330	}
331
332	if priorityArg, exists := arguments["priority"]; exists && priorityArg != nil {
333		priorityStr, ok := priorityArg.(string)
334		if !ok {
335			return reportMCPError("Invalid type for 'priority' argument: expected string.")
336		}
337		priorityMap := map[string]int{
338			"lowest":  -2,
339			"low":     -1,
340			"neutral": 0,
341			"high":    1,
342			"highest": 2,
343		}
344		translatedPriority, isValid := priorityMap[strings.ToLower(priorityStr)]
345		if !isValid {
346			return reportMCPError(fmt.Sprintf("Invalid 'priority' value: '%s'. Must be one of 'lowest', 'low', 'neutral', 'high', 'highest'.", priorityStr))
347		}
348		updatePayload.Priority = translatedPriority
349	}
350
351	if motivationArg, exists := arguments["motivation"]; exists {
352		if motivationStr, ok := motivationArg.(string); ok {
353			if motivationStr != "" {
354				validMotivations := map[string]bool{"must": true, "should": true, "want": true}
355				if !validMotivations[motivationStr] {
356					return reportMCPError("'motivation' must be one of 'must', 'should', or 'want', or empty to clear.")
357				}
358			}
359			updatePayload.Motivation = motivationStr
360		} else if !ok && motivationArg != nil {
361			return reportMCPError("Invalid type for motivation argument: expected string.")
362		}
363	}
364
365	if statusArg, exists := arguments["status"]; exists {
366		if statusStr, ok := statusArg.(string); ok {
367			if statusStr != "" {
368				validStatus := map[string]bool{"later": true, "next": true, "started": true, "waiting": true, "completed": true}
369				if !validStatus[statusStr] {
370					return reportMCPError("'status' must be one of 'later', 'next', 'started', 'waiting', 'completed', or empty.")
371				}
372			}
373			updatePayload.Status = statusStr
374		} else if !ok && statusArg != nil {
375			return reportMCPError("Invalid type for status argument: expected string.")
376		}
377	}
378
379	if scheduledOnArg, exists := arguments["scheduled_on"]; exists {
380		if scheduledOnStr, ok := scheduledOnArg.(string); ok {
381			if scheduledOnStr != "" {
382				if _, err := time.Parse(time.RFC3339, scheduledOnStr); err != nil {
383					return reportMCPError(fmt.Sprintf("Invalid format for scheduled_on: '%s'. Must be RFC3339. Use get_task_timestamp tool.", scheduledOnStr))
384				}
385			}
386			updatePayload.ScheduledOn = scheduledOnStr
387		} else if !ok && scheduledOnArg != nil {
388			return reportMCPError("Invalid type for scheduled_on argument: expected string.")
389		}
390	}
391
392	client := lunatask.NewClient(h.config.AccessToken)
393	response, err := client.UpdateTask(ctx, taskID, &updatePayload)
394	if err != nil {
395		return reportMCPError(fmt.Sprintf("Failed to update task: %v", err))
396	}
397
398	return &mcp.CallToolResult{
399		Content: []mcp.Content{
400			mcp.TextContent{
401				Type: "text",
402				Text: fmt.Sprintf("Task updated successfully. ID: %s", response.Task.ID),
403			},
404		},
405	}, nil
406}
407
408// HandleDeleteTask handles the delete_task tool call.
409func (h *Handlers) HandleDeleteTask(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
410	taskID, ok := request.Params.Arguments["task_id"].(string)
411	if !ok || taskID == "" {
412		return reportMCPError("Missing or invalid required argument: task_id")
413	}
414
415	client := lunatask.NewClient(h.config.AccessToken)
416	_, err := client.DeleteTask(ctx, taskID)
417	if err != nil {
418		return reportMCPError(fmt.Sprintf("Failed to delete task: %v", err))
419	}
420
421	return &mcp.CallToolResult{
422		Content: []mcp.Content{
423			mcp.TextContent{
424				Type: "text",
425				Text: "Task deleted successfully.",
426			},
427		},
428	}, nil
429}
430
431// HandleListHabitsAndActivities handles the list_habits_and_activities tool call.
432func (h *Handlers) HandleListHabitsAndActivities(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
433	var b strings.Builder
434	for _, habit := range h.config.Habits {
435		fmt.Fprintf(&b, "- %s: %s\n", habit.GetName(), habit.GetID())
436	}
437	return &mcp.CallToolResult{
438		Content: []mcp.Content{
439			mcp.TextContent{
440				Type: "text",
441				Text: b.String(),
442			},
443		},
444	}, nil
445}
446
447// HandleTrackHabitActivity handles the track_habit_activity tool call.
448func (h *Handlers) HandleTrackHabitActivity(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
449	habitID, ok := request.Params.Arguments["habit_id"].(string)
450	if !ok || habitID == "" {
451		return reportMCPError("Missing or invalid required argument: habit_id")
452	}
453
454	performedOn, ok := request.Params.Arguments["performed_on"].(string)
455	if !ok || performedOn == "" {
456		return reportMCPError("Missing or invalid required argument: performed_on")
457	}
458
459	client := lunatask.NewClient(h.config.AccessToken)
460	habitRequest := &lunatask.TrackHabitActivityRequest{
461		PerformedOn: performedOn,
462	}
463
464	resp, err := client.TrackHabitActivity(ctx, habitID, habitRequest)
465	if err != nil {
466		return reportMCPError(fmt.Sprintf("Failed to track habit activity: %v", err))
467	}
468
469	return &mcp.CallToolResult{
470		Content: []mcp.Content{
471			mcp.TextContent{
472				Type: "text",
473				Text: fmt.Sprintf("Habit activity tracked successfully. Status: %s, Message: %s", resp.Status, resp.Message),
474			},
475		},
476	}, nil
477}