feat: add modify_task tool for task updates

Amolith and Crush created

Add new MCP tool to modify task title and/or description with
automatic ID regeneration. The tool accepts task_id (required) and
optional title/description fields, updating only provided fields while
maintaining existing content for omitted ones.

Includes proper validation, logging, and returns updated task list.
Updates documentation across AGENTS.md and README.md.

Co-Authored-By: Crush <crush@charm.land>

Change summary

AGENTS.md                    |  8 +++--
README.md                    |  4 ++
internal/mcp/server.go       | 42 +++++++++++++++++++++++++++++++
internal/mcp/types.go        |  7 +++++
internal/mcp/validator.go    | 22 ++++++++++++++++
internal/planning/manager.go | 50 ++++++++++++++++++++++++++++++++++++++
internal/planning/types.go   | 13 +++++++++
7 files changed, 142 insertions(+), 4 deletions(-)

Detailed changes

AGENTS.md 🔗

@@ -10,13 +10,14 @@ This file provides guidance to AI coding assistants when working with code in th
 
 ## planning-mcp-server capabilities
 
-The server provides six essential planning tools:
+The server provides seven essential planning tools:
 
 - **`set_goal`**: Set the initial goal for your planning session (title and description required). Returns error if goal already exists.
 - **`change_goal`**: Change an existing goal with a required reason for the change. Only used when operator explicitly requests clearing/changing the goal.
-- **`add_tasks`**: Add one or more tasks to work on. Break tasks down into the smallest units of work possible and track progress. Each task requires a title (and optional description).
+- **`add_tasks`**: Add one or more tasks to work on. Break them down into the smallest units of work possible and track progress. Each task requires a title (and optional description).
 - **`get_tasks`**: Get task list with optional status filtering and indicators. Call frequently to stay organized.
 - **`update_task_statuses`**: Update the status of one or more tasks. Maintain your planning workflow by updating statuses regularly.
+- **`modify_task`**: Modify the title and/or description of a task. ID and at least one of title/description are required. When one or the other is omitted, that field will not be modified.
 - **`delete_tasks`**: Delete one or more tasks by their IDs and return the updated task list. Only use if the operator explicitly requests clearing the board.
 
 ## Development Commands
@@ -84,13 +85,14 @@ The server uses in-memory storage only. All goals and tasks are lost when the se
 
 ### MCP Tool Implementation
 
-The server exposes six MCP tools that map directly to planning manager methods:
+The server exposes seven MCP tools that map directly to planning manager methods:
 
 - `set_goal(title: string, description: string)`: Sets initial goal with title and description (both required). Returns error if goal already exists.
 - `change_goal(title: string, description: string, reason: string)`: Changes existing goal (all parameters required). Only used when operator explicitly requests clearing/changing the goal.
 - `add_tasks(tasks: []TaskInput)`: Batch task creation with duplicate detection. Each task requires `title` (required) and `description` (optional). Encourages breaking tasks down into smallest units of work and regular progress tracking. Output behavior depends on existing tasks: shows verbose instructions + task list when no tasks existed previously, shows brief task list (like `get_tasks`) when tasks already existed.
 - `get_tasks(status: string)`: Returns markdown-formatted task list with optional status filter (all, pending, in_progress, completed, cancelled, failed). Default is "all". Should be called frequently to stay organized.
 - `update_task_statuses(tasks: []TaskUpdate)`: Updates status of one or more tasks and returns full list. Never cancels tasks autonomously - marks as failed on errors and asks operator for guidance.
+- `modify_task(task_id: string, title?: string, description?: string)`: Modifies the title and/or description of a task. ID and at least one of title/description are required. When one or the other is omitted, that field will not be modified. Regenerates the task ID based on new content and returns the updated task list.
 - `delete_tasks(task_ids: []string)`: Deletes one or more tasks by their IDs. Only used when operator explicitly requests clearing the board. Otherwise, tasks should be marked as cancelled/failed. Returns the resulting task list.
 
 ### Configuration System

README.md 🔗

@@ -10,13 +10,14 @@ A Model Context Protocol (MCP) server that provides planning tools for LLMs to t
 
 ## What this gives your AI assistant
 
-The server provides six essential planning tools:
+The server provides seven essential planning tools:
 
 - **`set_goal`**: Set the initial goal for your planning session (title and description required). Returns error if goal already exists.
 - **`change_goal`**: Change an existing goal with a required reason for the change. Only used when operator explicitly requests clearing/changing the goal.
 - **`add_tasks`**: Add one or more tasks to work on. Break tasks down into the smallest units of work possible and track progress. Each task requires a title (and optional description).
 - **`get_tasks`**: Get task list with optional status filtering and indicators. Call frequently to stay organized.
 - **`update_task_statuses`**: Update the status of one or more tasks. Maintain your planning workflow by updating statuses regularly.
+- **`modify_task`**: Modify the title and/or description of a task. ID and at least one of title/description are required. When one or the other is omitted, that field will not be modified.
 - **`delete_tasks`**: Delete one or more tasks by their IDs and return the updated task list. Only use if the operator explicitly requests clearing the board.
 
 ## Installation
@@ -127,6 +128,7 @@ Task IDs are deterministically generated based on the task title and description
 - Same task content always gets the same ID within a session
 - No collisions for different tasks
 - Consistent references during the current session
+- When modifying tasks, IDs are regenerated to reflect the new content
 
 ## Data Storage
 

internal/mcp/server.go 🔗

@@ -157,6 +157,22 @@ func (s *Server) registerTools(mcpServer *server.MCPServer) {
 		),
 	)
 	mcpServer.AddTool(deleteTasksTool, s.handleDeleteTasks)
+
+	// Register modify_task tool
+	modifyTaskTool := mcp.NewTool("modify_task",
+		mcp.WithDescription("Modify the title and/or description of a task. ID and at least one of title/description are required. When one or the other is omitted, that field will not be modified."),
+		mcp.WithString("task_id",
+			mcp.Required(),
+			mcp.Description("ID of the task to modify"),
+		),
+		mcp.WithString("title",
+			mcp.Description("New title for the task (optional - if omitted, title remains unchanged)"),
+		),
+		mcp.WithString("description",
+			mcp.Description("New description for the task (optional - if omitted, description remains unchanged)"),
+		),
+	)
+	mcpServer.AddTool(modifyTaskTool, s.handleModifyTask)
 }
 
 // handleSetGoal handles the set_goal tool call
@@ -346,6 +362,32 @@ func (s *Server) handleDeleteTasks(ctx context.Context, request mcp.CallToolRequ
 	return createSuccessResult(taskList), nil
 }
 
+// handleModifyTask handles the modify_task tool call
+func (s *Server) handleModifyTask(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
+	s.logger.Info("Received modify_task tool call")
+
+	// Parse request
+	var req ModifyTaskRequest
+	if err := parseRequest(request.GetArguments(), &req); err != nil {
+		return createErrorResult(fmt.Sprintf("Invalid request format: %v", err)), nil
+	}
+
+	// Validate request
+	if err := s.validator.ValidateModifyTaskRequest(req); err != nil {
+		return createErrorResult(fmt.Sprintf("Validation error: %v", err)), nil
+	}
+
+	// Modify task
+	if err := s.planner.ModifyTask(req.TaskID, req.Title, req.Description); err != nil {
+		s.logger.Error("Failed to modify task", "error", err)
+		return createErrorResult(fmt.Sprintf("Error modifying task: %v", err)), nil
+	}
+
+	// Return full task list
+	taskList := s.planner.GetTasks()
+	return createSuccessResult(taskList), nil
+}
+
 // GetServer returns the underlying MCP server
 func (s *Server) GetServer() *server.MCPServer {
 	return s.server

internal/mcp/types.go 🔗

@@ -58,6 +58,13 @@ type DeleteTasksRequest struct {
 	TaskIDs []string `json:"task_ids" validate:"required,min=1"`
 }
 
+// ModifyTaskRequest represents the request structure for modify_task
+type ModifyTaskRequest struct {
+	TaskID      string `json:"task_id" validate:"required"`
+	Title       string `json:"title,omitempty"`
+	Description string `json:"description,omitempty"`
+}
+
 // parseRequest is a generic helper function to parse map[string]any to struct without validation
 func parseRequest[T any](arguments map[string]any, dest *T) error {
 	// Convert map to JSON then unmarshal to struct

internal/mcp/validator.go 🔗

@@ -19,6 +19,7 @@ type Validator interface {
 	ValidateGetTasksRequest(req GetTasksRequest) error
 	ValidateUpdateTaskStatusesRequest(req UpdateTaskStatusesRequest) error
 	ValidateDeleteTasksRequest(req DeleteTasksRequest) error
+	ValidateModifyTaskRequest(req ModifyTaskRequest) error
 }
 
 // PlanningValidator implements the Validator interface with configuration-based validation
@@ -151,3 +152,24 @@ func (v *PlanningValidator) ValidateDeleteTasksRequest(req DeleteTasksRequest) e
 	}
 	return nil
 }
+
+// ValidateModifyTaskRequest validates a modify task request
+func (v *PlanningValidator) ValidateModifyTaskRequest(req ModifyTaskRequest) error {
+	if req.TaskID == "" {
+		return errors.New("task_id is required")
+	}
+
+	if req.Title == "" && req.Description == "" {
+		return errors.New("at least one of title or description must be provided")
+	}
+
+	if req.Title != "" && len(req.Title) > v.config.Planning.MaxTaskLength {
+		return fmt.Errorf("title too long (max %d characters)", v.config.Planning.MaxTaskLength)
+	}
+
+	if req.Description != "" && len(req.Description) > v.config.Planning.MaxTaskLength {
+		return fmt.Errorf("description too long (max %d characters)", v.config.Planning.MaxTaskLength)
+	}
+
+	return nil
+}

internal/planning/manager.go 🔗

@@ -331,6 +331,56 @@ type TaskUpdate struct {
 	Status TaskStatus `json:"status"`
 }
 
+// ModifyTask updates the title and/or description of a task and regenerates its ID
+func (m *Manager) ModifyTask(taskID, title, description string) error {
+	if title == "" && description == "" {
+		return fmt.Errorf("at least one of title or description must be provided")
+	}
+
+	m.mu.Lock()
+	defer m.mu.Unlock()
+
+	task, exists := m.tasks[taskID]
+	if !exists {
+		return fmt.Errorf("task not found: %s", taskID)
+	}
+
+	// Validate new title if provided
+	if title != "" {
+		if len(title) > m.config.Planning.MaxTaskLength {
+			return fmt.Errorf("task title too long (max %d characters)", m.config.Planning.MaxTaskLength)
+		}
+	}
+
+	// Validate new description if provided
+	if description != "" {
+		if len(description) > m.config.Planning.MaxTaskLength {
+			return fmt.Errorf("task description too long (max %d characters)", m.config.Planning.MaxTaskLength)
+		}
+	}
+
+	// Store old values for logging
+	oldTitle := task.Title
+	oldDescription := task.Description
+
+	// Update content and regenerate ID
+	task.UpdateContent(title, description)
+
+	// Update the task in the map with the new ID
+	delete(m.tasks, taskID)
+	m.tasks[task.ID] = task
+
+	m.logger.Info("Task modified",
+		"old_id", taskID,
+		"new_id", task.ID,
+		"old_title", oldTitle,
+		"new_title", task.Title,
+		"old_description", oldDescription,
+		"new_description", task.Description)
+
+	return nil
+}
+
 // DeleteTasks deletes one or more tasks by their IDs
 func (m *Manager) DeleteTasks(taskIDs []string) error {
 	m.mu.Lock()

internal/planning/types.go 🔗

@@ -95,6 +95,19 @@ func (t *Task) UpdateStatus(status TaskStatus) {
 	t.UpdatedAt = time.Now()
 }
 
+// UpdateContent updates the task title and/or description and regenerates ID
+func (t *Task) UpdateContent(title, description string) {
+	if title != "" {
+		t.Title = title
+	}
+	if description != "" {
+		t.Description = description
+	}
+	// Regenerate ID based on new content
+	t.ID = generateTaskID(t.Title, t.Description)
+	t.UpdatedAt = time.Now()
+}
+
 // generateTaskID creates a deterministic 8-character ID based on task content
 func generateTaskID(title, description string) string {
 	content := fmt.Sprintf("%s:%s", title, description)