refactor: Split main.go into cmd/main.go and tools/handlers.go

Amolith created

Change summary

cmd/lunatask-mcp-server.go | 356 +++++++++++++++++
main.go                    | 796 ----------------------------------------
tools/handlers.go          | 477 +++++++++++++++++++++++
3 files changed, 833 insertions(+), 796 deletions(-)

Detailed changes

cmd/lunatask-mcp-server.go 🔗

@@ -0,0 +1,356 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+package main
+
+import (
+	"context"
+	"fmt"
+	"log"
+	"os"
+
+	"github.com/BurntSushi/toml"
+	"github.com/mark3labs/mcp-go/mcp"
+	"github.com/mark3labs/mcp-go/server"
+
+	"git.sr.ht/~amolith/lunatask-mcp-server/tools"
+)
+
+// Goal represents a Lunatask goal with its name and ID
+type Goal struct {
+	Name string `toml:"name"`
+	ID   string `toml:"id"`
+}
+
+// GetName returns the goal's name.
+func (g Goal) GetName() string { return g.Name }
+
+// GetID returns the goal's ID.
+func (g Goal) GetID() string { return g.ID }
+
+// Area represents a Lunatask area with its name, ID, and its goals
+type Area struct {
+	Name  string `toml:"name"`
+	ID    string `toml:"id"`
+	Goals []Goal `toml:"goals"`
+}
+
+// GetName returns the area's name.
+func (a Area) GetName() string { return a.Name }
+
+// GetID returns the area's ID.
+func (a Area) GetID() string { return a.ID }
+
+// GetGoals returns the area's goals as a slice of tools.GoalProvider.
+func (a Area) GetGoals() []tools.GoalProvider {
+	providers := make([]tools.GoalProvider, len(a.Goals))
+	for i, g := range a.Goals {
+		providers[i] = g // Goal implements GoalProvider
+	}
+	return providers
+}
+
+// Habit represents a Lunatask habit with its name and ID
+type Habit struct {
+	Name string `toml:"name"`
+	ID   string `toml:"id"`
+}
+
+// GetName returns the habit's name.
+func (h Habit) GetName() string { return h.Name }
+
+// GetID returns the habit's ID.
+func (h Habit) GetID() string { return h.ID }
+
+// ServerConfig holds the application's configuration loaded from TOML
+type ServerConfig struct {
+	Host string `toml:"host"`
+	Port int    `toml:"port"`
+}
+
+type Config struct {
+	AccessToken string       `toml:"access_token"`
+	Areas       []Area       `toml:"areas"`
+	Server      ServerConfig `toml:"server"`
+	Timezone    string       `toml:"timezone"`
+	Habit       []Habit      `toml:"habit"`
+}
+
+var version = ""
+
+func main() {
+	configPath := "./config.toml"
+	for i, arg := range os.Args {
+		switch arg {
+		case "-v", "--version":
+			if version == "" {
+				version = "unknown, build with `just build` or copy/paste the build command from ./justfile"
+			}
+			fmt.Println("lunatask-mcp-server:", version)
+			os.Exit(0)
+		case "-c", "--config":
+			if i+1 < len(os.Args) {
+				configPath = os.Args[i+1]
+			}
+		}
+	}
+
+	if _, err := os.Stat(configPath); os.IsNotExist(err) {
+		createDefaultConfigFile(configPath)
+	}
+
+	var config Config
+	if _, err := toml.DecodeFile(configPath, &config); err != nil {
+		log.Fatalf("Failed to load config file %s: %v", configPath, err)
+	}
+
+	if config.AccessToken == "" || len(config.Areas) == 0 {
+		log.Fatalf("Config file must provide access_token and at least one area.")
+	}
+
+	for i, area := range config.Areas {
+		if area.Name == "" || area.ID == "" {
+			log.Fatalf("All areas (areas[%d]) must have both a name and id", i)
+		}
+		for j, goal := range area.Goals {
+			if goal.Name == "" || goal.ID == "" {
+				log.Fatalf("All goals (areas[%d].goals[%d]) must have both a name and id", i, j)
+			}
+		}
+	}
+
+	// Validate timezone config on startup
+	if _, err := tools.LoadLocation(config.Timezone); err != nil {
+		log.Fatalf("Timezone validation failed: %v", err)
+	}
+
+	mcpServer := NewMCPServer(&config)
+
+	baseURL := fmt.Sprintf("http://%s:%d", config.Server.Host, config.Server.Port)
+	sseServer := server.NewSSEServer(mcpServer, server.WithBaseURL(baseURL))
+	listenAddr := fmt.Sprintf("%s:%d", config.Server.Host, config.Server.Port)
+	log.Printf("SSE server listening on %s (baseURL: %s)", listenAddr, baseURL)
+	if err := sseServer.Start(listenAddr); err != nil {
+		log.Fatalf("Server error: %v", err)
+	}
+}
+
+// closeFile properly closes a file, handling any errors
+func closeFile(f *os.File) {
+	err := f.Close()
+	if err != nil {
+		log.Printf("Error closing file: %v", err)
+	}
+}
+
+func NewMCPServer(appConfig *Config) *server.MCPServer {
+	hooks := &server.Hooks{}
+
+	hooks.AddBeforeAny(func(ctx context.Context, id any, method mcp.MCPMethod, message any) {
+		fmt.Printf("beforeAny: %s, %v, %v\n", method, id, message)
+	})
+	hooks.AddOnSuccess(func(ctx context.Context, id any, method mcp.MCPMethod, message any, result any) {
+		fmt.Printf("onSuccess: %s, %v, %v, %v\n", method, id, message, result)
+	})
+	hooks.AddOnError(func(ctx context.Context, id any, method mcp.MCPMethod, message any, err error) {
+		fmt.Printf("onError: %s, %v, %v, %v\n", method, id, message, err)
+	})
+	hooks.AddBeforeInitialize(func(ctx context.Context, id any, message *mcp.InitializeRequest) {
+		fmt.Printf("beforeInitialize: %v, %v\n", id, message)
+	})
+	hooks.AddAfterInitialize(func(ctx context.Context, id any, message *mcp.InitializeRequest, result *mcp.InitializeResult) {
+		fmt.Printf("afterInitialize: %v, %v, %v\n", id, message, result)
+	})
+	hooks.AddAfterCallTool(func(ctx context.Context, id any, message *mcp.CallToolRequest, result *mcp.CallToolResult) {
+		fmt.Printf("afterCallTool: %v, %v, %v\n", id, message, result)
+	})
+	hooks.AddBeforeCallTool(func(ctx context.Context, id any, message *mcp.CallToolRequest) {
+		fmt.Printf("beforeCallTool: %v, %v\n", id, message)
+	})
+
+	mcpServer := server.NewMCPServer(
+		"Lunatask MCP Server",
+		"0.1.0",
+		server.WithHooks(hooks),
+		server.WithToolCapabilities(true),
+	)
+
+	// Prepare AreaProviders
+	areaProviders := make([]tools.AreaProvider, len(appConfig.Areas))
+	for i, area := range appConfig.Areas {
+		areaProviders[i] = area // Area implements AreaProvider
+	}
+
+	// Prepare HabitProviders
+	habitProviders := make([]tools.HabitProvider, len(appConfig.Habit))
+	for i, habit := range appConfig.Habit {
+		habitProviders[i] = habit // Habit implements HabitProvider
+	}
+
+	handlerCfg := tools.HandlerConfig{
+		AccessToken: appConfig.AccessToken,
+		Timezone:    appConfig.Timezone,
+		Areas:       areaProviders,
+		Habits:      habitProviders,
+	}
+	toolHandlers := tools.NewHandlers(handlerCfg)
+
+	mcpServer.AddTool(mcp.NewTool("get_timestamp",
+		mcp.WithDescription("Retrieves the formatted timestamp for a task"),
+		mcp.WithString("natural_language_date",
+			mcp.Description("Natural language date as described by the user, e.g. '1 week', 'tomorrow', 'sunday at 19:00', 'now', etc."),
+			mcp.Required(),
+		),
+	), toolHandlers.HandleGetTimestamp)
+
+	mcpServer.AddTool(
+		mcp.NewTool(
+			"list_areas_and_goals",
+			mcp.WithDescription("List areas and goals and their IDs."),
+		),
+		toolHandlers.HandleListAreasAndGoals,
+	)
+
+	mcpServer.AddTool(mcp.NewTool("create_task",
+		mcp.WithDescription("Creates a new task"),
+		mcp.WithString("area_id",
+			mcp.Description("Area ID in which to create the task"),
+			mcp.Required(),
+		),
+		mcp.WithString("goal_id",
+			mcp.Description("Goal ID, which must belong to the provided area, to associate the task with."),
+		),
+		mcp.WithString("name",
+			mcp.Description("Plain text task name using sentence case."),
+			mcp.Required(),
+		),
+		mcp.WithString("note",
+			mcp.Description("Note attached to the task, optionally Markdown-formatted"),
+		),
+		mcp.WithNumber("estimate",
+			mcp.Description("Estimated time completion time in minutes"),
+			mcp.Min(0),
+			mcp.Max(1440),
+		),
+		mcp.WithString("priority",
+			mcp.Description("Task priority, omit unless priority is mentioned"),
+			mcp.Enum("lowest", "low", "neutral", "high", "highest"),
+		),
+		mcp.WithString("motivation",
+			mcp.Description("Motivation driving task creation"),
+			mcp.Enum("must", "should", "want"),
+		),
+		mcp.WithString("status",
+			mcp.Description("Task state, such as in progress, provided as 'started', already started, provided as 'started', soon, provided as 'next', blocked as waiting, omit unspecified, and so on. Intuit the task's status."),
+			mcp.Enum("later", "next", "started", "waiting", "completed"),
+		),
+		mcp.WithString("scheduled_on",
+			mcp.Description("Formatted timestamp from get_task_timestamp tool"),
+		),
+	), toolHandlers.HandleCreateTask)
+
+	mcpServer.AddTool(mcp.NewTool("update_task",
+		mcp.WithDescription("Updates an existing task. Only provided fields will be targeted for update."),
+		mcp.WithString("task_id",
+			mcp.Description("ID of the task to update."),
+			mcp.Required(),
+		),
+		mcp.WithString("area_id",
+			mcp.Description("New Area ID for the task. Must be a valid Area ID from 'list_areas_and_goals'."),
+		),
+		mcp.WithString("goal_id",
+			mcp.Description("New Goal ID for the task. Must be a valid Goal ID from 'list_areas_and_goals'."),
+		),
+		mcp.WithString("name",
+			mcp.Description("New plain text task name using sentence case. Sending an empty string WILL clear the name."),
+			mcp.Required(),
+		),
+		mcp.WithString("note",
+			mcp.Description("New note attached to the task, optionally Markdown-formatted. Sending an empty string WILL clear the note."),
+		),
+		mcp.WithNumber("estimate",
+			mcp.Description("New estimated time completion time in minutes."),
+			mcp.Min(0),
+			mcp.Max(720), // Aligned with CreateTaskRequest validation tag
+		),
+		mcp.WithString("priority",
+			mcp.Description("Task priority, omit unless priority is mentioned"),
+			mcp.Enum("lowest", "low", "neutral", "high", "highest"),
+		),
+		mcp.WithString("motivation",
+			mcp.Description("New motivation driving the task."),
+			mcp.Enum("must", "should", "want", ""), // Allow empty string to potentially clear/unset
+		),
+		mcp.WithString("status",
+			mcp.Description("New task state."),
+			mcp.Enum("later", "next", "started", "waiting", "completed", ""), // Allow empty string
+		),
+		mcp.WithString("scheduled_on",
+			mcp.Description("New scheduled date/time as a formatted timestamp from get_task_timestamp tool. Sending an empty string might clear the scheduled date."),
+		),
+	), toolHandlers.HandleUpdateTask)
+
+	mcpServer.AddTool(mcp.NewTool("delete_task",
+		mcp.WithDescription("Deletes an existing task"),
+		mcp.WithString("task_id",
+			mcp.Description("ID of the task to delete."),
+			mcp.Required(),
+		),
+	), toolHandlers.HandleDeleteTask)
+
+	mcpServer.AddTool(
+		mcp.NewTool(
+			"list_habits_and_activities",
+			mcp.WithDescription("List habits and their IDs for tracking or marking complete with tracking_habit_activity"),
+		),
+		toolHandlers.HandleListHabitsAndActivities,
+	)
+
+	mcpServer.AddTool(mcp.NewTool("track_habit_activity",
+		mcp.WithDescription("Tracks an activity or a habit in Lunatask"),
+		mcp.WithString("habit_id",
+			mcp.Description("ID of the habit to track activity for."),
+			mcp.Required(),
+		),
+		mcp.WithString("performed_on",
+			mcp.Description("The timestamp the habit was performed, first obtained with get_timestamp."),
+			mcp.Required(),
+		),
+	), toolHandlers.HandleTrackHabitActivity)
+
+	return mcpServer
+}
+
+func createDefaultConfigFile(configPath string) {
+	defaultConfig := Config{
+		Server: ServerConfig{
+			Host: "localhost",
+			Port: 8080,
+		},
+		AccessToken: "",
+		Timezone:    "UTC",
+		Areas: []Area{{
+			Name: "Example Area",
+			ID:   "area-id-placeholder",
+			Goals: []Goal{{
+				Name: "Example Goal",
+				ID:   "goal-id-placeholder",
+			}},
+		}},
+		Habit: []Habit{{
+			Name: "Example Habit",
+			ID:   "habit-id-placeholder",
+		}},
+	}
+	file, err := os.OpenFile(configPath, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0o600)
+	if err != nil {
+		log.Fatalf("Failed to create default config at %s: %v", configPath, err)
+	}
+	defer closeFile(file)
+	if err := toml.NewEncoder(file).Encode(defaultConfig); err != nil {
+		log.Fatalf("Failed to encode default config to %s: %v", configPath, err)
+	}
+	fmt.Printf("A default config has been created at %s.\nPlease edit it to provide your Lunatask access token, correct area/goal IDs, and your timezone (IANA/Olson format, e.g. 'America/New_York'), then restart the server.\n", configPath)
+	os.Exit(1)
+}

main.go 🔗

@@ -1,796 +0,0 @@
-// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
-//
-// SPDX-License-Identifier: AGPL-3.0-or-later
-
-package main
-
-import (
-	"context"
-	"encoding/json"
-	"fmt"
-	"log"
-	"os"
-	"strings"
-	"time"
-
-	"github.com/ijt/go-anytime"
-
-	"github.com/BurntSushi/toml"
-	"github.com/mark3labs/mcp-go/mcp"
-	"github.com/mark3labs/mcp-go/server"
-
-	"git.sr.ht/~amolith/lunatask-mcp-server/lunatask"
-)
-
-// Goal represents a Lunatask goal with its name and ID
-type Goal struct {
-	Name string `toml:"name"`
-	ID   string `toml:"id"`
-}
-
-// Area represents a Lunatask area with its name, ID, and its goals
-type Area struct {
-	Name  string `toml:"name"`
-	ID    string `toml:"id"`
-	Goals []Goal `toml:"goals"`
-}
-
-type Habit struct {
-	Name string `toml:"name"`
-	ID   string `toml:"id"`
-}
-
-// Config holds the application's configuration loaded from TOML
-type ServerConfig struct {
-	Host string `toml:"host"`
-	Port int    `toml:"port"`
-}
-
-type Config struct {
-	AccessToken string       `toml:"access_token"`
-	Areas       []Area       `toml:"areas"`
-	Server      ServerConfig `toml:"server"`
-	Timezone    string       `toml:"timezone"`
-	Habit       []Habit      `toml:"habit"`
-}
-
-var version = ""
-
-func main() {
-	configPath := "./config.toml"
-	for i, arg := range os.Args {
-		switch arg {
-		case "-v", "--version":
-			if version == "" {
-				version = "unknown, build with `just build` or copy/paste the build command from ./justfile"
-			}
-			fmt.Println("lunatask-mcp-server:", version)
-			os.Exit(0)
-		case "-c", "--config":
-			if i+1 < len(os.Args) {
-				configPath = os.Args[i+1]
-			}
-		}
-	}
-
-	if _, err := os.Stat(configPath); os.IsNotExist(err) {
-		createDefaultConfigFile(configPath)
-	}
-
-	var config Config
-	if _, err := toml.DecodeFile(configPath, &config); err != nil {
-		log.Fatalf("Failed to load config file %s: %v", configPath, err)
-	}
-
-	if config.AccessToken == "" || len(config.Areas) == 0 {
-		log.Fatalf("Config file must provide access_token and at least one area.")
-	}
-
-	for i, area := range config.Areas {
-		if area.Name == "" || area.ID == "" {
-			log.Fatalf("All areas (areas[%d]) must have both a name and id", i)
-		}
-		for j, goal := range area.Goals {
-			if goal.Name == "" || goal.ID == "" {
-				log.Fatalf("All goals (areas[%d].goals[%d]) must have both a name and id", i, j)
-			}
-		}
-	}
-
-	// Validate timezone config on startup
-	if _, err := loadLocation(config.Timezone); err != nil {
-		log.Fatalf("Timezone validation failed: %v", err)
-	}
-
-	mcpServer := NewMCPServer(&config)
-
-	baseURL := fmt.Sprintf("http://%s:%d", config.Server.Host, config.Server.Port)
-	sseServer := server.NewSSEServer(mcpServer, server.WithBaseURL(baseURL))
-	listenAddr := fmt.Sprintf("%s:%d", config.Server.Host, config.Server.Port)
-	log.Printf("SSE server listening on %s (baseURL: %s)", listenAddr, baseURL)
-	if err := sseServer.Start(listenAddr); err != nil {
-		log.Fatalf("Server error: %v", err)
-	}
-}
-
-// 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
-}
-
-// closeFile properly closes a file, handling any errors
-func closeFile(f *os.File) {
-	err := f.Close()
-	if err != nil {
-		log.Printf("Error closing file: %v", err)
-	}
-}
-
-func NewMCPServer(config *Config) *server.MCPServer {
-	hooks := &server.Hooks{}
-
-	hooks.AddBeforeAny(func(ctx context.Context, id any, method mcp.MCPMethod, message any) {
-		fmt.Printf("beforeAny: %s, %v, %v\n", method, id, message)
-	})
-	hooks.AddOnSuccess(func(ctx context.Context, id any, method mcp.MCPMethod, message any, result any) {
-		fmt.Printf("onSuccess: %s, %v, %v, %v\n", method, id, message, result)
-	})
-	hooks.AddOnError(func(ctx context.Context, id any, method mcp.MCPMethod, message any, err error) {
-		fmt.Printf("onError: %s, %v, %v, %v\n", method, id, message, err)
-	})
-	hooks.AddBeforeInitialize(func(ctx context.Context, id any, message *mcp.InitializeRequest) {
-		fmt.Printf("beforeInitialize: %v, %v\n", id, message)
-	})
-	hooks.AddAfterInitialize(func(ctx context.Context, id any, message *mcp.InitializeRequest, result *mcp.InitializeResult) {
-		fmt.Printf("afterInitialize: %v, %v, %v\n", id, message, result)
-	})
-	hooks.AddAfterCallTool(func(ctx context.Context, id any, message *mcp.CallToolRequest, result *mcp.CallToolResult) {
-		fmt.Printf("afterCallTool: %v, %v, %v\n", id, message, result)
-	})
-	hooks.AddBeforeCallTool(func(ctx context.Context, id any, message *mcp.CallToolRequest) {
-		fmt.Printf("beforeCallTool: %v, %v\n", id, message)
-	})
-
-	mcpServer := server.NewMCPServer(
-		"Lunatask MCP Server",
-		"0.1.0",
-		server.WithHooks(hooks),
-		server.WithToolCapabilities(true),
-	)
-
-	mcpServer.AddTool(mcp.NewTool("get_timestamp",
-		mcp.WithDescription("Retrieves the formatted timestamp for a task"),
-		mcp.WithString("natural_language_date",
-			mcp.Description("Natural language date as described by the user, e.g. '1 week', 'tomorrow', 'sunday at 19:00', 'now', etc."),
-			mcp.Required(),
-		),
-	), func(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(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
-	})
-
-	mcpServer.AddTool(
-		mcp.NewTool(
-			"list_areas_and_goals",
-			mcp.WithDescription("List areas and goals and their IDs."),
-		),
-		func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-			var b strings.Builder
-			for _, area := range config.Areas {
-				fmt.Fprintf(&b, "- %s: %s\n", area.Name, area.ID)
-				for _, goal := range area.Goals {
-					fmt.Fprintf(&b, "  - %s: %s\n", goal.Name, goal.ID)
-				}
-			}
-			return &mcp.CallToolResult{
-				Content: []mcp.Content{
-					mcp.TextContent{
-						Type: "text",
-						Text: b.String(),
-					},
-				},
-			}, nil
-		},
-	)
-
-	mcpServer.AddTool(mcp.NewTool("create_task",
-		mcp.WithDescription("Creates a new task"),
-		mcp.WithString("area_id",
-			mcp.Description("Area ID in which to create the task"),
-			mcp.Required(),
-		),
-		mcp.WithString("goal_id",
-			mcp.Description("Goal ID, which must belong to the provided area, to associate the task with."),
-		),
-		mcp.WithString("name",
-			mcp.Description("Plain text task name using sentence case."),
-			mcp.Required(),
-		),
-		mcp.WithString("note",
-			mcp.Description("Note attached to the task, optionally Markdown-formatted"),
-		),
-		mcp.WithNumber("estimate",
-			mcp.Description("Estimated time completion time in minutes"),
-			mcp.Min(0),
-			mcp.Max(1440),
-		),
-		mcp.WithString("priority",
-			mcp.Description("Task priority, omit unless priority is mentioned"),
-			mcp.Enum("lowest", "low", "neutral", "high", "highest"),
-		),
-		mcp.WithString("motivation",
-			mcp.Description("Motivation driving task creation"),
-			mcp.Enum("must", "should", "want"),
-		),
-		mcp.WithString("status",
-			mcp.Description("Task state, such as in progress, provided as 'started', already started, provided as 'started', soon, provided as 'next', blocked as waiting, omit unspecified, and so on. Intuit the task's status."),
-			mcp.Enum("later", "next", "started", "waiting", "completed"),
-		),
-		mcp.WithString("scheduled_on",
-			mcp.Description("Formatted timestamp from get_task_timestamp tool"),
-		),
-	), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-		return handleCreateTask(ctx, request, config)
-	})
-
-	mcpServer.AddTool(mcp.NewTool("update_task",
-		mcp.WithDescription("Updates an existing task. Only provided fields will be targeted for update."),
-		mcp.WithString("task_id",
-			mcp.Description("ID of the task to update."),
-			mcp.Required(),
-		),
-		mcp.WithString("area_id",
-			mcp.Description("New Area ID for the task. Must be a valid Area ID from 'list_areas_and_goals'."),
-		),
-		mcp.WithString("goal_id",
-			mcp.Description("New Goal ID for the task. Must be a valid Goal ID from 'list_areas_and_goals'."),
-		),
-		mcp.WithString("name",
-			mcp.Description("New plain text task name using sentence case. Sending an empty string WILL clear the name."),
-			mcp.Required(),
-		),
-		mcp.WithString("note",
-			mcp.Description("New note attached to the task, optionally Markdown-formatted. Sending an empty string WILL clear the note."),
-		),
-		mcp.WithNumber("estimate",
-			mcp.Description("New estimated time completion time in minutes."),
-			mcp.Min(0),
-			mcp.Max(720), // Aligned with CreateTaskRequest validation tag
-		),
-		mcp.WithString("priority",
-			mcp.Description("Task priority, omit unless priority is mentioned"),
-			mcp.Enum("lowest", "low", "neutral", "high", "highest"),
-		),
-		mcp.WithString("motivation",
-			mcp.Description("New motivation driving the task."),
-			mcp.Enum("must", "should", "want", ""), // Allow empty string to potentially clear/unset
-		),
-		mcp.WithString("status",
-			mcp.Description("New task state."),
-			mcp.Enum("later", "next", "started", "waiting", "completed", ""), // Allow empty string
-		),
-		mcp.WithString("scheduled_on",
-			mcp.Description("New scheduled date/time as a formatted timestamp from get_task_timestamp tool. Sending an empty string might clear the scheduled date."),
-		),
-	), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-		return handleUpdateTask(ctx, request, config)
-	})
-
-	mcpServer.AddTool(mcp.NewTool("delete_task",
-		mcp.WithDescription("Deletes an existing task"),
-		mcp.WithString("task_id",
-			mcp.Description("ID of the task to delete."),
-			mcp.Required(),
-		),
-	), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-		return handleDeleteTask(ctx, request, config)
-	})
-
-	mcpServer.AddTool(
-		mcp.NewTool(
-			"list_habits_and_activities",
-			mcp.WithDescription("List habits and their IDs for tracking or marking complete with tracking_habit_activity"),
-		),
-		func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-			var b strings.Builder
-			for _, habit := range config.Habit {
-				fmt.Fprintf(&b, "- %s: %s\n", habit.Name, habit.ID)
-			}
-			return &mcp.CallToolResult{
-				Content: []mcp.Content{
-					mcp.TextContent{
-						Type: "text",
-						Text: b.String(),
-					},
-				},
-			}, nil
-		},
-	)
-
-	mcpServer.AddTool(mcp.NewTool("track_habit_activity",
-		mcp.WithDescription("Tracks an activity or a habit in Lunatask"),
-		mcp.WithString("habit_id",
-			mcp.Description("ID of the habit to track activity for."),
-			mcp.Required(),
-		),
-		mcp.WithString("performed_on",
-			mcp.Description("The timestamp the habit was performed, first obtained with get_timestamp."),
-			mcp.Required(),
-		),
-	), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-		return handleTrackHabitActivity(ctx, request, config)
-	})
-
-	return mcpServer
-}
-
-func reportMCPError(msg string) (*mcp.CallToolResult, error) {
-	return &mcp.CallToolResult{
-		IsError: true,
-		Content: []mcp.Content{mcp.TextContent{Type: "text", Text: msg}},
-	}, nil
-}
-
-// handleCreateTask handles the creation of a task in Lunatask
-func handleCreateTask(
-	ctx context.Context,
-	request mcp.CallToolRequest,
-	config *Config,
-) (*mcp.CallToolResult, error) {
-	arguments := request.Params.Arguments
-
-	// Validate timezone before proceeding any further
-	if _, err := loadLocation(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 area *Area
-	for i := range config.Areas {
-		if config.Areas[i].ID == areaID {
-			area = &config.Areas[i]
-			break
-		}
-	}
-	if area == nil {
-		return reportMCPError("Area not found for given area_id")
-	}
-
-	if goalID, exists := arguments["goal_id"].(string); exists && goalID != "" {
-		found := false
-		for _, goal := range area.Goals {
-			if goal.ID == goalID {
-				found = true
-				break
-			}
-		}
-		if !found {
-			return reportMCPError("Goal not found in specified area for given goal_id")
-		}
-	}
-
-	// Priority translation and validation
-	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 {
-			// This should ideally be caught by MCP schema validation if type is string.
-			return reportMCPError("Invalid type for 'priority' argument: expected string.")
-		}
-		// An empty string for priority is not valid as it's not in the enum.
-		// The map lookup will fail for an empty string, triggering the !isValid block.
-
-		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 // Update the map with the integer value
-	}
-
-	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")
-		}
-	}
-
-	// Validate scheduled_on format if provided
-	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 {
-			// It exists but isn't a string, which shouldn't happen based on MCP schema but check anyway
-			return reportMCPError("Invalid type for scheduled_on argument: expected string.")
-		}
-		// If it's an empty string, it's handled by the API or omitempty later, no need to validate format.
-	}
-
-	// Create Lunatask client
-	client := lunatask.NewClient(config.AccessToken)
-
-	// Prepare the task request
-	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))
-	}
-
-	// Call the client to create the task
-	response, err := client.CreateTask(ctx, &task)
-	if err != nil {
-		return reportMCPError(fmt.Sprintf("%v", err))
-	}
-
-	// Handle the case where task already exists
-	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 of a task in Lunatask
-func handleUpdateTask(
-	ctx context.Context,
-	request mcp.CallToolRequest,
-	config *Config,
-) (*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")
-	}
-
-	// Validate timezone before proceeding, as it might be used by API or for scheduled_on
-	if _, err := loadLocation(config.Timezone); err != nil {
-		return reportMCPError(err.Error())
-	}
-
-	updatePayload := lunatask.CreateTaskRequest{} // Reusing CreateTaskRequest for the update body
-
-	var specifiedArea *Area // Used for goal validation if area_id is also specified
-	areaIDProvided := false
-
-	if areaIDArg, exists := arguments["area_id"]; exists {
-		if areaIDStr, ok := areaIDArg.(string); ok && areaIDStr != "" {
-			updatePayload.AreaID = areaIDStr
-			areaIDProvided = true
-			found := false
-			for i := range config.Areas {
-				if config.Areas[i].ID == areaIDStr {
-					specifiedArea = &config.Areas[i]
-					found = true
-					break
-				}
-			}
-			if !found {
-				return reportMCPError(fmt.Sprintf("Area not found for given area_id: %s", areaIDStr))
-			}
-		} else if !ok && areaIDArg != nil { // Exists but not a string
-			return reportMCPError("Invalid type for area_id argument: expected string.")
-		}
-		// If areaIDArg is an empty string or nil, it's fine, AreaID in payload will be "" (or not set if using pointers/map)
-		// With CreateTaskRequest, it will be "" if not explicitly set to a non-empty string.
-	}
-
-	if goalIDArg, exists := arguments["goal_id"]; exists {
-		if goalIDStr, ok := goalIDArg.(string); ok && goalIDStr != "" {
-			updatePayload.GoalID = goalIDStr
-			// If goal_id is specified, but area_id is not, we cannot validate the goal against a specific area from config.
-			// The API will have to handle this. For stricter local validation, one might require area_id here.
-			// For now, we proceed, assuming the API can handle it or the goal is in the task's current (unchanged) area.
-			// If area_id WAS provided, specifiedArea would be set.
-			// If area_id was NOT provided, we need to check all areas, or rely on API.
-			// Let's enforce that if goal_id is given, and area_id is also given, the goal must be in that area.
-			// If goal_id is given and area_id is NOT, we can't validate locally.
-			// The description for goal_id parameter hints at this.
-			if specifiedArea != nil { // Only validate goal if its intended area (new or existing) is known
-				foundGoal := false
-				for _, goal := range specifiedArea.Goals {
-					if goal.ID == goalIDStr {
-						foundGoal = true
-						break
-					}
-				}
-				if !foundGoal {
-					return reportMCPError(fmt.Sprintf("Goal not found in specified area '%s' for given goal_id: %s", specifiedArea.Name, goalIDStr))
-				}
-			} else if areaIDProvided { // area_id was provided but somehow specifiedArea is nil (should be caught above)
-				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.")
-		}
-	}
-
-	// Name is now required by the MCP tool definition.
-	// The lunatask.ValidateTask (called by client.UpdateTask) will ensure it's not an empty string
-	// due to the "required" tag on CreateTaskRequest.Name.
-	nameArg := arguments["name"] // MCP framework ensures "name" exists.
-	if nameStr, ok := nameArg.(string); ok {
-		updatePayload.Name = nameStr
-	} else {
-		// This case should ideally be caught by MCP's type checking.
-		// A defensive check is good.
-		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 {
-			// Validation for min/max (0-720) is in CreateTaskRequest struct tags,
-			// checked by lunatask.ValidateTask.
-			// MCP tool also defines this range.
-			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 != "" { // Allow empty string to be passed if desired (e.g. to clear)
-				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 != "" { // Allow empty string
-				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 != "" { // Allow empty string to potentially clear scheduled_on
-				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.")
-		}
-	}
-
-	// Create Lunatask client
-	client := lunatask.NewClient(config.AccessToken)
-
-	// Call the client to update the task
-	// The updatePayload (CreateTaskRequest) will be validated by client.UpdateTask->ValidateTask
-	response, err := client.UpdateTask(ctx, taskID, &updatePayload)
-	if err != nil {
-		return reportMCPError(fmt.Sprintf("Failed to update task: %v", err))
-	}
-
-	// The API returns the updated task details.
-	// We can construct a more detailed message if needed, e.g., by marshaling response.Task to JSON.
-	return &mcp.CallToolResult{
-		Content: []mcp.Content{
-			mcp.TextContent{
-				Type: "text",
-				Text: fmt.Sprintf("Task updated successfully. ID: %s", response.Task.ID),
-			},
-		},
-	}, nil
-}
-
-func handleDeleteTask(
-	ctx context.Context,
-	request mcp.CallToolRequest,
-	config *Config,
-) (*mcp.CallToolResult, error) {
-	taskID, ok := request.Params.Arguments["task_id"].(string)
-	if !ok || taskID == "" {
-		return reportMCPError("Missing or invalid required argument: task_id")
-	}
-
-	// Create the Lunatask client
-	client := lunatask.NewClient(config.AccessToken)
-
-	// Delete the task
-	_, err := client.DeleteTask(ctx, taskID)
-	if err != nil {
-		return reportMCPError(fmt.Sprintf("Failed to delete task: %v", err))
-	}
-
-	// Return success response
-	return &mcp.CallToolResult{
-		Content: []mcp.Content{
-			mcp.TextContent{
-				Type: "text",
-				Text: "Task deleted successfully.",
-			},
-		},
-	}, nil
-}
-
-// handleTrackHabitActivity handles tracking a habit activity in Lunatask
-func handleTrackHabitActivity(
-	ctx context.Context,
-	request mcp.CallToolRequest,
-	config *Config,
-) (*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")
-	}
-
-	// Create the Lunatask client
-	client := lunatask.NewClient(config.AccessToken)
-
-	// Create the request
-	habitRequest := &lunatask.TrackHabitActivityRequest{
-		PerformedOn: performedOn,
-	}
-
-	// Track the habit activity
-	resp, err := client.TrackHabitActivity(ctx, habitID, habitRequest)
-	if err != nil {
-		return reportMCPError(fmt.Sprintf("Failed to track habit activity: %v", err))
-	}
-
-	// Return success response
-	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
-}
-
-func createDefaultConfigFile(configPath string) {
-	defaultConfig := Config{
-		Server: ServerConfig{
-			Host: "localhost",
-			Port: 8080,
-		},
-		AccessToken: "",
-		Timezone:    "UTC",
-		Areas: []Area{{
-			Name: "Example Area",
-			ID:   "area-id-placeholder",
-			Goals: []Goal{{
-				Name: "Example Goal",
-				ID:   "goal-id-placeholder",
-			}},
-		}},
-		Habit: []Habit{{
-			Name: "Example Habit",
-			ID:   "habit-id-placeholder",
-		}},
-	}
-	file, err := os.OpenFile(configPath, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0o600)
-	if err != nil {
-		log.Fatalf("Failed to create default config at %s: %v", configPath, err)
-	}
-	defer closeFile(file)
-	if err := toml.NewEncoder(file).Encode(defaultConfig); err != nil {
-		log.Fatalf("Failed to encode default config to %s: %v", configPath, err)
-	}
-	fmt.Printf("A default config has been created at %s.\nPlease edit it to provide your Lunatask access token, correct area/goal IDs, and your timezone (IANA/Olson format, e.g. 'America/New_York'), then restart the server.\n", configPath)
-	os.Exit(1)
-}

tools/handlers.go 🔗

@@ -0,0 +1,477 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+package tools
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"strings"
+	"time"
+
+	"github.com/ijt/go-anytime"
+	"github.com/mark3labs/mcp-go/mcp"
+
+	"git.sr.ht/~amolith/lunatask-mcp-server/lunatask"
+)
+
+// 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
+}
+
+// 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
+}
+
+// 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
+}