feat: Add update_task tool and Lunatask client method

Amolith created

Change summary

lunatask/tasks.go |  75 ++++++++++++++++
main.go           | 221 ++++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 296 insertions(+)

Detailed changes

lunatask/tasks.go 🔗

@@ -80,6 +80,11 @@ type CreateTaskResponse struct {
 	} `json:"task"`
 }
 
+// UpdateTaskResponse represents the response from Lunatask API when updating a task
+type UpdateTaskResponse struct {
+	Task Task `json:"task"`
+}
+
 // ValidationError represents errors returned by the validator
 type ValidationError struct {
 	Field   string
@@ -182,3 +187,73 @@ func (c *Client) CreateTask(ctx context.Context, task *CreateTaskRequest) (*Crea
 
 	return &response, nil
 }
+
+// UpdateTask updates an existing task in Lunatask
+func (c *Client) UpdateTask(ctx context.Context, taskID string, task *CreateTaskRequest) (*UpdateTaskResponse, error) {
+	if taskID == "" {
+		return nil, errors.New("task ID cannot be empty")
+	}
+
+	// Validate the task payload
+	// Note: ValidateTask checks fields like Name, Priority, Estimate, etc.
+	// It's assumed that the API handles partial updates correctly,
+	// especially for fields like Name or AreaID in CreateTaskRequest that lack `omitempty`.
+	if err := ValidateTask(task); err != nil {
+		return nil, err
+	}
+
+	// Marshal the task to JSON
+	payloadBytes, err := json.Marshal(task)
+	if err != nil {
+		return nil, fmt.Errorf("failed to marshal payload: %w", err)
+	}
+
+	// Create the request
+	req, err := http.NewRequestWithContext(
+		ctx,
+		"PUT",
+		fmt.Sprintf("%s/tasks/%s", c.BaseURL, taskID),
+		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 {
+				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)
+		// Consider specific handling for 404 Not Found if needed
+		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 UpdateTaskResponse
+	err = json.Unmarshal(respBody, &response)
+	if err != nil {
+		return nil, fmt.Errorf("failed to parse response: %w", err)
+	}
+
+	return &response, nil
+}

main.go 🔗

@@ -253,6 +253,49 @@ func NewMCPServer(config *Config) *server.MCPServer {
 		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 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.WithString("name",
+			mcp.Description("New plain text task name using sentence case."),
+		),
+		mcp.WithString("note",
+			mcp.Description("New note attached to the task, optionally Markdown-formatted. Sending an empty string might 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.WithNumber("priority",
+			mcp.Description("New task priority, -2 being lowest, 0 being normal, and 2 being highest."),
+			mcp.Min(-2),
+			mcp.Max(2),
+		),
+		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)
+	})
+
 	return mcpServer
 }
 
@@ -395,6 +438,184 @@ func handleCreateTask(
 	}, 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 !areaIDProvided && specifiedArea == nil {
+				// 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.")
+		}
+	}
+
+	if nameArg, exists := arguments["name"]; exists {
+		if nameStr, ok := nameArg.(string); ok { // Allow empty string for name to potentially clear it
+			updatePayload.Name = nameStr
+		} else if !ok && nameArg != nil {
+			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 {
+		if priorityVal, ok := priorityArg.(float64); ok {
+			if priorityVal < -2 || priorityVal > 2 { // MCP tool range
+				return reportMCPError("'priority' must be between -2 and 2 (inclusive).")
+			}
+			updatePayload.Priority = int(priorityVal)
+		} else {
+			return reportMCPError("Invalid type for priority argument: expected number.")
+		}
+	}
+
+	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 createDefaultConfigFile(configPath string) {
 	defaultConfig := Config{
 		Server: ServerConfig{