refactor: Split tools handlers into separate files

Amolith created

Change summary

tools/habits.go |  62 +++++++++
tools/tasks.go  | 327 +++++++++++++++++++++++++++++++++++++++++++++++++++
tools/tools.go  | 115 +++++++++++++++++
3 files changed, 504 insertions(+)

Detailed changes

tools/habits.go 🔗

@@ -0,0 +1,62 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// 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
+}

tools/tasks.go 🔗

@@ -0,0 +1,327 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// 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
+}

tools/tools.go 🔗

@@ -0,0 +1,115 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// 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
+}