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