@@ -3,3 +3,117 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
package lunatask
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "strings"
+
+ "github.com/go-playground/validator/v10"
+)
+
+// TrackHabitActivityRequest represents the request to track a habit activity
+type TrackHabitActivityRequest struct {
+ PerformedOn string `json:"performed_on" validate:"required,datetime"`
+}
+
+// TrackHabitActivityResponse represents the response from Lunatask API when tracking a habit activity
+type TrackHabitActivityResponse struct {
+ Status string `json:"status"`
+ Message string `json:"message,omitempty"`
+}
+
+// ValidateTrackHabitActivity validates the track habit activity request
+func ValidateTrackHabitActivity(request *TrackHabitActivityRequest) error {
+ validate := validator.New()
+ if err := validate.Struct(request); err != nil {
+ var invalidValidationError *validator.InvalidValidationError
+ if errors.As(err, &invalidValidationError) {
+ return fmt.Errorf("invalid validation error: %w", err)
+ }
+
+ var validationErrs validator.ValidationErrors
+ if errors.As(err, &validationErrs) {
+ var msgBuilder strings.Builder
+ msgBuilder.WriteString("habit activity validation failed:")
+ for _, e := range validationErrs {
+ fmt.Fprintf(&msgBuilder, " field '%s' failed '%s' validation (was: %v);", e.Field(), e.Tag(), e.Value())
+ }
+ return errors.New(msgBuilder.String())
+ }
+ return fmt.Errorf("validation error: %w", err)
+ }
+ return nil
+}
+
+// TrackHabitActivity tracks an activity for a habit in Lunatask
+func (c *Client) TrackHabitActivity(ctx context.Context, habitID string, request *TrackHabitActivityRequest) (*TrackHabitActivityResponse, error) {
+ if habitID == "" {
+ return nil, errors.New("habit ID cannot be empty")
+ }
+
+ // Validate the request
+ if err := ValidateTrackHabitActivity(request); err != nil {
+ return nil, err
+ }
+
+ // Marshal the request to JSON
+ payloadBytes, err := json.Marshal(request)
+ if err != nil {
+ return nil, fmt.Errorf("failed to marshal payload: %w", err)
+ }
+
+ // Create the request
+ req, err := http.NewRequestWithContext(
+ ctx,
+ "POST",
+ fmt.Sprintf("%s/habits/%s/track", c.BaseURL, habitID),
+ bytes.NewBuffer(payloadBytes),
+ )
+ if err != nil {
+ return nil, fmt.Errorf("failed to create HTTP request: %w", err)
+ }
+
+ // Set headers
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("Authorization", "bearer "+c.AccessToken)
+
+ // Send the request
+ resp, err := c.HTTPClient.Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("failed to send HTTP request: %w", err)
+ }
+ defer func() {
+ if resp.Body != nil {
+ if err := resp.Body.Close(); err != nil {
+ // We're in a defer, so we can only log the error
+ fmt.Printf("Error closing response body: %v\n", err)
+ }
+ }
+ }()
+
+ // Handle error status codes
+ if resp.StatusCode < 200 || resp.StatusCode >= 300 {
+ respBody, _ := io.ReadAll(resp.Body)
+ return nil, fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(respBody))
+ }
+
+ // Read and parse the response
+ respBody, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, fmt.Errorf("failed to read response body: %w", err)
+ }
+
+ var response TrackHabitActivityResponse
+ err = json.Unmarshal(respBody, &response)
+ if err != nil {
+ return nil, fmt.Errorf("failed to parse response: %w", err)
+ }
+
+ return &response, nil
+}
@@ -35,6 +35,11 @@ type Area struct {
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"`
@@ -46,6 +51,7 @@ type Config struct {
Areas []Area `toml:"areas"`
Server ServerConfig `toml:"server"`
Timezone string `toml:"timezone"`
+ Habits []Habit `toml:"habits"`
}
var version = ""
@@ -159,10 +165,10 @@ func NewMCPServer(config *Config) *server.MCPServer {
server.WithToolCapabilities(true),
)
- mcpServer.AddTool(mcp.NewTool("get_task_timestamp",
+ 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', etc."),
+ 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) {
@@ -212,6 +218,27 @@ func NewMCPServer(config *Config) *server.MCPServer {
},
)
+ mcpServer.AddTool(
+ mcp.NewTool(
+ "list_habits",
+ mcp.WithDescription("List habits and their IDs."),
+ ),
+ func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
+ var b strings.Builder
+ for _, habit := range config.Habits {
+ 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("create_task",
mcp.WithDescription("Creates a new task"),
mcp.WithString("area_id",
@@ -263,14 +290,14 @@ func NewMCPServer(config *Config) *server.MCPServer {
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 belong to the provided 'area_id' (if 'area_id' is also being updated) or the task's current area. If 'goal_id' is specified, 'area_id' must also be specified if you intend to validate the goal against a new area, or the goal must exist in the task's current area."),
+ 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."),
+ 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 might clear the 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."),
@@ -307,6 +334,20 @@ func NewMCPServer(config *Config) *server.MCPServer {
return handleDeleteTask(ctx, request, config)
})
+ mcpServer.AddTool(mcp.NewTool("track_habit_activity",
+ mcp.WithDescription("Tracks an activity for 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
}
@@ -634,22 +675,21 @@ func handleDeleteTask(
request mcp.CallToolRequest,
config *Config,
) (*mcp.CallToolResult, error) {
- arguments := request.Params.Arguments
-
- taskID, ok := arguments["task_id"].(string)
+ taskID, ok := request.Params.Arguments["task_id"].(string)
if !ok || taskID == "" {
return reportMCPError("Missing or invalid required argument: task_id")
}
- // Create Lunatask client
+ // Create the Lunatask client
client := lunatask.NewClient(config.AccessToken)
- // Call the client to delete the task
+ // 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{
@@ -660,6 +700,47 @@ func handleDeleteTask(
}, 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{
@@ -676,6 +757,10 @@ func createDefaultConfigFile(configPath string) {
ID: "goal-id-placeholder",
}},
}},
+ Habits: []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 {