From 61c437bff916a553ceb8a1fddedc8e626cd2a8e2 Mon Sep 17 00:00:00 2001 From: Amolith Date: Sun, 11 May 2025 15:19:55 -0600 Subject: [PATCH] feat: Add update_task tool and Lunatask client method --- lunatask/tasks.go | 75 ++++++++++++++++ main.go | 221 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 296 insertions(+) diff --git a/lunatask/tasks.go b/lunatask/tasks.go index 12fad020227b69d428790d46fb4224bd40a4de51..23c12ccd0062cbba97d0a7d63d685c73c7e4b8cb 100644 --- a/lunatask/tasks.go +++ b/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 +} diff --git a/main.go b/main.go index 47f9c9ef128add5894d07e4600e3d0f824bba6ea..40594773aacd8e6b93b61533d02aa22aed636ebc 100644 --- a/main.go +++ b/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{