From 983032203a0a64c562ad18f8f37aec362f537f7e Mon Sep 17 00:00:00 2001 From: Amolith Date: Thu, 15 May 2025 22:59:43 -0600 Subject: [PATCH] refactor: Split main.go into cmd/main.go and tools/handlers.go --- cmd/lunatask-mcp-server.go | 356 +++++++++++++++++ main.go | 796 ------------------------------------- tools/handlers.go | 477 ++++++++++++++++++++++ 3 files changed, 833 insertions(+), 796 deletions(-) create mode 100644 cmd/lunatask-mcp-server.go delete mode 100644 main.go create mode 100644 tools/handlers.go diff --git a/cmd/lunatask-mcp-server.go b/cmd/lunatask-mcp-server.go new file mode 100644 index 0000000000000000000000000000000000000000..3dfd41bfeb96cd20fb123f4c7ded6beade34764c --- /dev/null +++ b/cmd/lunatask-mcp-server.go @@ -0,0 +1,356 @@ +// SPDX-FileCopyrightText: Amolith +// +// 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) +} diff --git a/main.go b/main.go deleted file mode 100644 index 15890fd92862df7beb19d174ec6c09383065c4bb..0000000000000000000000000000000000000000 --- a/main.go +++ /dev/null @@ -1,796 +0,0 @@ -// SPDX-FileCopyrightText: Amolith -// -// 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) -} diff --git a/tools/handlers.go b/tools/handlers.go new file mode 100644 index 0000000000000000000000000000000000000000..75fc8d58e788446572cd1f688e2a12b76969c32d --- /dev/null +++ b/tools/handlers.go @@ -0,0 +1,477 @@ +// SPDX-FileCopyrightText: Amolith +// +// 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 +}