@@ -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)
+}
@@ -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)
-}
@@ -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
+}