From e1c384885bf9a751fa55db33251cbd03a26d59c1 Mon Sep 17 00:00:00 2001 From: Amolith Date: Sun, 18 May 2025 11:09:32 -0600 Subject: [PATCH] refactor: Split tools handlers into separate files --- tools/habits.go | 62 +++++++++ tools/tasks.go | 327 ++++++++++++++++++++++++++++++++++++++++++++++++ tools/tools.go | 115 +++++++++++++++++ 3 files changed, 504 insertions(+) create mode 100644 tools/habits.go create mode 100644 tools/tasks.go create mode 100644 tools/tools.go diff --git a/tools/habits.go b/tools/habits.go new file mode 100644 index 0000000000000000000000000000000000000000..0dcd93661f2e1976cd1d59755a5f0568dd80d696 --- /dev/null +++ b/tools/habits.go @@ -0,0 +1,62 @@ +// SPDX-FileCopyrightText: Amolith +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +package tools + +import ( + "context" + "fmt" + "strings" + + "git.sr.ht/~amolith/lunatask-mcp-server/lunatask" + "github.com/mark3labs/mcp-go/mcp" +) + +// HandleListHabitsAndActivities handles the list_habits_and_activities tool call. +func (h *Handlers) HandleListHabitsAndActivities(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + var b strings.Builder + for _, habit := range h.config.Habits { + fmt.Fprintf(&b, "- %s: %s\n", habit.GetName(), habit.GetID()) + } + return &mcp.CallToolResult{ + Content: []mcp.Content{ + mcp.TextContent{ + Type: "text", + Text: b.String(), + }, + }, + }, nil +} + +// HandleTrackHabitActivity handles the track_habit_activity tool call. +func (h *Handlers) HandleTrackHabitActivity(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + habitID, ok := request.Params.Arguments["habit_id"].(string) + if !ok || habitID == "" { + return reportMCPError("Missing or invalid required argument: habit_id") + } + + performedOn, ok := request.Params.Arguments["performed_on"].(string) + if !ok || performedOn == "" { + return reportMCPError("Missing or invalid required argument: performed_on") + } + + client := lunatask.NewClient(h.config.AccessToken) + habitRequest := &lunatask.TrackHabitActivityRequest{ + PerformedOn: performedOn, + } + + resp, err := client.TrackHabitActivity(ctx, habitID, habitRequest) + if err != nil { + return reportMCPError(fmt.Sprintf("Failed to track habit activity: %v", err)) + } + + return &mcp.CallToolResult{ + Content: []mcp.Content{ + mcp.TextContent{ + Type: "text", + Text: fmt.Sprintf("Habit activity tracked successfully. Status: %s, Message: %s", resp.Status, resp.Message), + }, + }, + }, nil +} diff --git a/tools/tasks.go b/tools/tasks.go new file mode 100644 index 0000000000000000000000000000000000000000..d199d0c028fcbaba444b48568a046e89ab21aff7 --- /dev/null +++ b/tools/tasks.go @@ -0,0 +1,327 @@ +// SPDX-FileCopyrightText: Amolith +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +package tools + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "time" + + "git.sr.ht/~amolith/lunatask-mcp-server/lunatask" + "github.com/mark3labs/mcp-go/mcp" +) + +// HandleCreateTask handles the create_task tool call. +func (h *Handlers) HandleCreateTask(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + arguments := request.Params.Arguments + + if _, err := LoadLocation(h.config.Timezone); err != nil { + return reportMCPError(err.Error()) + } + + areaID, ok := arguments["area_id"].(string) + if !ok || areaID == "" { + return reportMCPError("Missing or invalid required argument: area_id") + } + + var areaFoundProvider AreaProvider + for _, ap := range h.config.Areas { + if ap.GetID() == areaID { + areaFoundProvider = ap + break + } + } + if areaFoundProvider == nil { + return reportMCPError("Area not found for given area_id") + } + + if goalID, exists := arguments["goal_id"].(string); exists && goalID != "" { + found := false + for _, goal := range areaFoundProvider.GetGoals() { + if goal.GetID() == goalID { + found = true + break + } + } + if !found { + return reportMCPError("Goal not found in specified area for given goal_id") + } + } + + priorityMap := map[string]int{ + "lowest": -2, + "low": -1, + "neutral": 0, + "high": 1, + "highest": 2, + } + + if priorityArg, exists := arguments["priority"]; exists && priorityArg != nil { + priorityStr, ok := priorityArg.(string) + if !ok { + return reportMCPError("Invalid type for 'priority' argument: expected string.") + } + translatedPriority, isValid := priorityMap[strings.ToLower(priorityStr)] + if !isValid { + return reportMCPError(fmt.Sprintf("Invalid 'priority' value: '%s'. Must be one of 'lowest', 'low', 'neutral', 'high', 'highest'.", priorityStr)) + } + arguments["priority"] = translatedPriority + } + + if motivationVal, exists := arguments["motivation"]; exists && motivationVal != nil { + if motivation, ok := motivationVal.(string); ok && motivation != "" { + validMotivations := map[string]bool{"must": true, "should": true, "want": true} + if !validMotivations[motivation] { + return reportMCPError("'motivation' must be one of 'must', 'should', or 'want'") + } + } else if ok { + // empty string is allowed + } else { + return reportMCPError("'motivation' must be a string") + } + } + + if statusVal, exists := arguments["status"]; exists && statusVal != nil { + if status, ok := statusVal.(string); ok && status != "" { + validStatus := map[string]bool{"later": true, "next": true, "started": true, "waiting": true, "completed": true} + if !validStatus[status] { + return reportMCPError("'status' must be one of 'later', 'next', 'started', 'waiting', or 'completed'") + } + } else if ok { + // empty string is allowed + } else { + return reportMCPError("'status' must be a string") + } + } + + if scheduledOnArg, exists := arguments["scheduled_on"]; exists { + if scheduledOnStr, ok := scheduledOnArg.(string); ok && scheduledOnStr != "" { + if _, err := time.Parse(time.RFC3339, scheduledOnStr); err != nil { + 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)) + } + } else if !ok { + return reportMCPError("Invalid type for scheduled_on argument: expected string.") + } + } + + client := lunatask.NewClient(h.config.AccessToken) + var task lunatask.CreateTaskRequest + argBytes, err := json.Marshal(arguments) + if err != nil { + return reportMCPError(fmt.Sprintf("Failed to process arguments: %v", err)) + } + if err := json.Unmarshal(argBytes, &task); err != nil { + return reportMCPError(fmt.Sprintf("Failed to parse arguments: %v", err)) + } + + response, err := client.CreateTask(ctx, &task) + if err != nil { + return reportMCPError(fmt.Sprintf("%v", err)) + } + + if response == nil { + return &mcp.CallToolResult{ + Content: []mcp.Content{ + mcp.TextContent{ + Type: "text", + Text: "Task already exists (not an error).", + }, + }, + }, nil + } + + return &mcp.CallToolResult{ + Content: []mcp.Content{ + mcp.TextContent{ + Type: "text", + Text: fmt.Sprintf("Task created successfully with ID: %s", response.Task.ID), + }, + }, + }, nil +} + +// HandleUpdateTask handles the update_task tool call. +func (h *Handlers) HandleUpdateTask(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + arguments := request.Params.Arguments + + taskID, ok := arguments["task_id"].(string) + if !ok || taskID == "" { + return reportMCPError("Missing or invalid required argument: task_id") + } + + if _, err := LoadLocation(h.config.Timezone); err != nil { + return reportMCPError(err.Error()) + } + + updatePayload := lunatask.CreateTaskRequest{} + + var specifiedAreaProvider AreaProvider + areaIDProvided := false + + if areaIDArg, exists := arguments["area_id"]; exists { + if areaIDStr, ok := areaIDArg.(string); ok && areaIDStr != "" { + updatePayload.AreaID = areaIDStr + areaIDProvided = true + found := false + for _, ap := range h.config.Areas { + if ap.GetID() == areaIDStr { + specifiedAreaProvider = ap + found = true + break + } + } + if !found { + return reportMCPError(fmt.Sprintf("Area not found for given area_id: %s", areaIDStr)) + } + } else if !ok && areaIDArg != nil { + return reportMCPError("Invalid type for area_id argument: expected string.") + } + } + + if goalIDArg, exists := arguments["goal_id"]; exists { + if goalIDStr, ok := goalIDArg.(string); ok && goalIDStr != "" { + updatePayload.GoalID = goalIDStr + if specifiedAreaProvider != nil { + foundGoal := false + for _, goal := range specifiedAreaProvider.GetGoals() { + if goal.GetID() == goalIDStr { + foundGoal = true + break + } + } + if !foundGoal { + return reportMCPError(fmt.Sprintf("Goal not found in specified area '%s' for given goal_id: %s", specifiedAreaProvider.GetName(), goalIDStr)) + } + } else if areaIDProvided { + return reportMCPError("Internal error: area_id provided but area details not loaded for goal validation.") + } + } else if !ok && goalIDArg != nil { + return reportMCPError("Invalid type for goal_id argument: expected string.") + } + } + + nameArg := arguments["name"] + if nameStr, ok := nameArg.(string); ok { + updatePayload.Name = nameStr + } else { + return reportMCPError("Invalid type for name argument: expected string.") + } + + if noteArg, exists := arguments["note"]; exists { + if noteStr, ok := noteArg.(string); ok { + updatePayload.Note = noteStr + } else if !ok && noteArg != nil { + return reportMCPError("Invalid type for note argument: expected string.") + } + } + + if estimateArg, exists := arguments["estimate"]; exists && estimateArg != nil { + if estimateVal, ok := estimateArg.(float64); ok { + updatePayload.Estimate = int(estimateVal) + } else { + return reportMCPError("Invalid type for estimate argument: expected number.") + } + } + + if priorityArg, exists := arguments["priority"]; exists && priorityArg != nil { + priorityStr, ok := priorityArg.(string) + if !ok { + return reportMCPError("Invalid type for 'priority' argument: expected string.") + } + priorityMap := map[string]int{ + "lowest": -2, + "low": -1, + "neutral": 0, + "high": 1, + "highest": 2, + } + translatedPriority, isValid := priorityMap[strings.ToLower(priorityStr)] + if !isValid { + return reportMCPError(fmt.Sprintf("Invalid 'priority' value: '%s'. Must be one of 'lowest', 'low', 'neutral', 'high', 'highest'.", priorityStr)) + } + updatePayload.Priority = translatedPriority + } + + if motivationArg, exists := arguments["motivation"]; exists { + if motivationStr, ok := motivationArg.(string); ok { + if motivationStr != "" { + validMotivations := map[string]bool{"must": true, "should": true, "want": true} + if !validMotivations[motivationStr] { + return reportMCPError("'motivation' must be one of 'must', 'should', or 'want', or empty to clear.") + } + } + updatePayload.Motivation = motivationStr + } else if !ok && motivationArg != nil { + return reportMCPError("Invalid type for motivation argument: expected string.") + } + } + + if statusArg, exists := arguments["status"]; exists { + if statusStr, ok := statusArg.(string); ok { + if statusStr != "" { + validStatus := map[string]bool{"later": true, "next": true, "started": true, "waiting": true, "completed": true} + if !validStatus[statusStr] { + return reportMCPError("'status' must be one of 'later', 'next', 'started', 'waiting', 'completed', or empty.") + } + } + updatePayload.Status = statusStr + } else if !ok && statusArg != nil { + return reportMCPError("Invalid type for status argument: expected string.") + } + } + + if scheduledOnArg, exists := arguments["scheduled_on"]; exists { + if scheduledOnStr, ok := scheduledOnArg.(string); ok { + if scheduledOnStr != "" { + if _, err := time.Parse(time.RFC3339, scheduledOnStr); err != nil { + return reportMCPError(fmt.Sprintf("Invalid format for scheduled_on: '%s'. Must be RFC3339. Use get_task_timestamp tool.", scheduledOnStr)) + } + } + updatePayload.ScheduledOn = scheduledOnStr + } else if !ok && scheduledOnArg != nil { + return reportMCPError("Invalid type for scheduled_on argument: expected string.") + } + } + + client := lunatask.NewClient(h.config.AccessToken) + response, err := client.UpdateTask(ctx, taskID, &updatePayload) + if err != nil { + return reportMCPError(fmt.Sprintf("Failed to update task: %v", err)) + } + + return &mcp.CallToolResult{ + Content: []mcp.Content{ + mcp.TextContent{ + Type: "text", + Text: fmt.Sprintf("Task updated successfully. ID: %s", response.Task.ID), + }, + }, + }, nil +} + +// HandleDeleteTask handles the delete_task tool call. +func (h *Handlers) HandleDeleteTask(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + taskID, ok := request.Params.Arguments["task_id"].(string) + if !ok || taskID == "" { + return reportMCPError("Missing or invalid required argument: task_id") + } + + client := lunatask.NewClient(h.config.AccessToken) + _, err := client.DeleteTask(ctx, taskID) + if err != nil { + return reportMCPError(fmt.Sprintf("Failed to delete task: %v", err)) + } + + return &mcp.CallToolResult{ + Content: []mcp.Content{ + mcp.TextContent{ + Type: "text", + Text: "Task deleted successfully.", + }, + }, + }, nil +} diff --git a/tools/tools.go b/tools/tools.go new file mode 100644 index 0000000000000000000000000000000000000000..00dc914af6f8ce2e7df8a0c08dcebada985ac68b --- /dev/null +++ b/tools/tools.go @@ -0,0 +1,115 @@ +// SPDX-FileCopyrightText: Amolith +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +package tools + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/ijt/go-anytime" + "github.com/mark3labs/mcp-go/mcp" +) + +// AreaProvider defines the interface for accessing area data. +type AreaProvider interface { + GetName() string + GetID() string + GetGoals() []GoalProvider +} + +// GoalProvider defines the interface for accessing goal data. +type GoalProvider interface { + GetName() string + GetID() string +} + +// HabitProvider defines the interface for accessing habit data. +type HabitProvider interface { + GetName() string + GetID() string +} + +// HandlerConfig holds the necessary configuration for tool handlers. +type HandlerConfig struct { + AccessToken string + Timezone string + Areas []AreaProvider + Habits []HabitProvider +} + +// Handlers provides methods for handling MCP tool calls. +type Handlers struct { + config HandlerConfig +} + +// NewHandlers creates a new Handlers instance. +func NewHandlers(config HandlerConfig) *Handlers { + return &Handlers{config: config} +} + +// reportMCPError creates an MCP error result. +func reportMCPError(msg string) (*mcp.CallToolResult, error) { + return &mcp.CallToolResult{ + IsError: true, + Content: []mcp.Content{mcp.TextContent{Type: "text", Text: msg}}, + }, nil +} + +// LoadLocation loads a timezone location string, returning a *time.Location or error +func LoadLocation(timezone string) (*time.Location, error) { + if timezone == "" { + return nil, fmt.Errorf("timezone is not configured; please set the 'timezone' value in your config file (e.g. 'UTC' or 'America/New_York')") + } + loc, err := time.LoadLocation(timezone) + if err != nil { + return nil, fmt.Errorf("could not load timezone '%s': %v", timezone, err) + } + return loc, nil +} + +// HandleGetTimestamp handles the get_timestamp tool call. +func (h *Handlers) HandleGetTimestamp(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + natLangDate, ok := request.Params.Arguments["natural_language_date"].(string) + if !ok || natLangDate == "" { + return reportMCPError("Missing or invalid required argument: natural_language_date") + } + loc, err := LoadLocation(h.config.Timezone) + if err != nil { + return reportMCPError(err.Error()) + } + parsedTime, err := anytime.Parse(natLangDate, time.Now().In(loc)) + if err != nil { + return reportMCPError(fmt.Sprintf("Could not parse natural language date: %v", err)) + } + return &mcp.CallToolResult{ + Content: []mcp.Content{ + mcp.TextContent{ + Type: "text", + Text: parsedTime.Format(time.RFC3339), + }, + }, + }, nil +} + +// HandleListAreasAndGoals handles the list_areas_and_goals tool call. +func (h *Handlers) HandleListAreasAndGoals(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + var b strings.Builder + for _, area := range h.config.Areas { + fmt.Fprintf(&b, "- %s: %s\n", area.GetName(), area.GetID()) + for _, goal := range area.GetGoals() { + fmt.Fprintf(&b, " - %s: %s\n", goal.GetName(), goal.GetID()) + } + } + return &mcp.CallToolResult{ + Content: []mcp.Content{ + mcp.TextContent{ + Type: "text", + Text: b.String(), + }, + }, + }, nil +}