diff --git a/AGENTS.md b/AGENTS.md index 3a536c7e728be87e264c9e110cb69bec3bdaf94c..e603fda88f0ed0e81ba280cf9c53b4ab3fa029e8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -56,7 +56,7 @@ The server exposes four MCP tools that map directly to planning manager methods: - `update_goal(goal: string)`: Sets overarching goal with length validation - `add_tasks(tasks: []TaskInput)`: Batch task creation with duplicate detection. 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()`: Returns markdown-formatted task list with legend and sorted by creation time. Should be called frequently to stay organized. -- `update_task_status(task_id: string, status: string)`: Updates task status and returns full list. Helps maintain planning workflow by tracking progress. +- `update_tasks(tasks: []TaskUpdate)`: Updates status of one or more tasks and returns full list. Helps maintain planning workflow by tracking progress. ### Configuration System diff --git a/README.md b/README.md index 4d0f39cc9467fd66196aae1fe8f2173e1b79156b..4f5e98a769fb2e2030c43237caaba5c913b31801 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ The server provides four essential planning tools: - **`update_goal`**: Set or update the overarching goal for your planning session - **`add_tasks`**: Add one or more tasks to work on. Break tasks down into the smallest units of work possible and track progress. - **`get_tasks`**: Get current task list with status indicators and legend. Call frequently to stay organized. -- **`update_task_status`**: Update the status of a specific task. Maintain your planning workflow by updating statuses regularly. +- **`update_tasks`**: Update the status of one or more tasks. Maintain your planning workflow by updating statuses regularly. ## Installation diff --git a/cmd/planning-mcp-server/main.go b/cmd/planning-mcp-server/main.go index 0182c85c2fe9bd43873ea4dd147845790056fa7b..a6078953aafb21e7ecf0958981843a86b3b462eb 100644 --- a/cmd/planning-mcp-server/main.go +++ b/cmd/planning-mcp-server/main.go @@ -54,7 +54,7 @@ The server provides tools for goal setting, task management, and progress tracki - update_goal: Set or update the overarching goal - add_tasks: Add one or more tasks to work on - get_tasks: Get current task list with status indicators -- update_task_status: Update the status of a specific task`, +- update_tasks: Update the status of one or more tasks`, SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { if showVersion { diff --git a/internal/mcp/server.go b/internal/mcp/server.go index eceb4deda143ade4752225846a9ab4e8d3bf6b79..f520c2f7403844b5e71d9cf6daa53dc9192c1967 100644 --- a/internal/mcp/server.go +++ b/internal/mcp/server.go @@ -98,19 +98,29 @@ func (s *Server) registerTools(mcpServer *server.MCPServer) { ) mcpServer.AddTool(getTasksTool, s.handleGetTasks) - // Register update_task_status tool - updateTaskStatusTool := mcp.NewTool("update_task_status", - mcp.WithDescription("Update the status of a specific task. Maintain your planning workflow by regularly updating task statuses as you make progress."), - mcp.WithString("task_id", - mcp.Required(), - mcp.Description("The task ID to update"), - ), - mcp.WithString("status", + // Register update_tasks tool + updateTasksTool := mcp.NewTool("update_tasks", + mcp.WithDescription("Update the status of one or more tasks. Maintain your planning workflow by regularly updating task statuses as you make progress."), + mcp.WithArray("tasks", mcp.Required(), - mcp.Description("New status: pending, in_progress, completed, or failed"), + mcp.Description("Array of task updates"), + mcp.Items(map[string]any{ + "type": "object", + "properties": map[string]any{ + "task_id": map[string]any{ + "type": "string", + "description": "The task ID to update", + }, + "status": map[string]any{ + "type": "string", + "description": "New status: pending, in_progress, completed, or failed", + }, + }, + "required": []string{"task_id", "status"}, + }), ), ) - mcpServer.AddTool(updateTaskStatusTool, s.handleUpdateTaskStatus) + mcpServer.AddTool(updateTasksTool, s.handleUpdateTasks) } // handleUpdateGoal handles the update_goal tool call @@ -284,49 +294,110 @@ func (s *Server) handleGetTasks(ctx context.Context, request mcp.CallToolRequest }, nil } -// handleUpdateTaskStatus handles the update_task_status tool call -func (s *Server) handleUpdateTaskStatus(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - s.logger.Info("Received update_task_status tool call") +// handleUpdateTasks handles the update_tasks tool call +func (s *Server) handleUpdateTasks(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + s.logger.Info("Received update_tasks tool call") // Extract parameters arguments := request.GetArguments() - taskID, ok := arguments["task_id"].(string) - if !ok || taskID == "" { + tasksRaw, ok := arguments["tasks"] + if !ok { return &mcp.CallToolResult{ Content: []mcp.Content{ mcp.TextContent{ Type: "text", - Text: "Error: task_id parameter is required and must be a string", + Text: "Error: tasks parameter is required", }, }, IsError: true, }, nil } - statusStr, ok := arguments["status"].(string) - if !ok || statusStr == "" { + // Convert to slice of interfaces + tasksSlice, ok := tasksRaw.([]any) + if !ok { + return &mcp.CallToolResult{ + Content: []mcp.Content{ + mcp.TextContent{ + Type: "text", + Text: "Error: tasks parameter must be an array", + }, + }, + IsError: true, + }, nil + } + + if len(tasksSlice) == 0 { return &mcp.CallToolResult{ Content: []mcp.Content{ mcp.TextContent{ Type: "text", - Text: "Error: status parameter is required and must be a string", + Text: "Error: at least one task update is required", }, }, IsError: true, }, nil } - // Parse status - status := planning.ParseStatus(statusStr) + // Parse task updates + updates := make([]planning.TaskUpdate, 0, len(tasksSlice)) + for _, taskRaw := range tasksSlice { + taskMap, ok := taskRaw.(map[string]any) + if !ok { + return &mcp.CallToolResult{ + Content: []mcp.Content{ + mcp.TextContent{ + Type: "text", + Text: "Error: each task update must be an object", + }, + }, + IsError: true, + }, nil + } + + taskID, ok := taskMap["task_id"].(string) + if !ok || taskID == "" { + return &mcp.CallToolResult{ + Content: []mcp.Content{ + mcp.TextContent{ + Type: "text", + Text: "Error: each task update must have a non-empty task_id", + }, + }, + IsError: true, + }, nil + } + + statusStr, ok := taskMap["status"].(string) + if !ok || statusStr == "" { + return &mcp.CallToolResult{ + Content: []mcp.Content{ + mcp.TextContent{ + Type: "text", + Text: "Error: each task update must have a non-empty status", + }, + }, + IsError: true, + }, nil + } + + // Parse status + status := planning.ParseStatus(statusStr) + + updates = append(updates, planning.TaskUpdate{ + TaskID: taskID, + Status: status, + }) + } - // Update task status - if err := s.planner.UpdateTaskStatus(taskID, status); err != nil { - s.logger.Error("Failed to update task status", "error", err) + // Update task statuses + if err := s.planner.UpdateTasks(updates); err != nil { + s.logger.Error("Failed to update task statuses", "error", err) return &mcp.CallToolResult{ Content: []mcp.Content{ mcp.TextContent{ Type: "text", - Text: fmt.Sprintf("Error updating task status: %v", err), + Text: fmt.Sprintf("Error updating task statuses: %v", err), }, }, IsError: true, diff --git a/internal/planning/manager.go b/internal/planning/manager.go index 26cf92526f57855ed6a5dd561840a62597bf55f9..0e6ad1d62940793ce1e64ac5072932724d4f0904 100644 --- a/internal/planning/manager.go +++ b/internal/planning/manager.go @@ -191,6 +191,28 @@ func (m *Manager) UpdateTaskStatus(taskID string, status TaskStatus) error { return nil } +// UpdateTasks updates the status of multiple tasks in a single operation +func (m *Manager) UpdateTasks(updates []TaskUpdate) error { + m.mu.Lock() + defer m.mu.Unlock() + + // First validate all task IDs exist + for _, update := range updates { + if _, exists := m.tasks[update.TaskID]; !exists { + return fmt.Errorf("task not found: %s", update.TaskID) + } + } + + // If all validations pass, apply all updates + for _, update := range updates { + task := m.tasks[update.TaskID] + task.UpdateStatus(update.Status) + m.logger.Info("Task status updated", "id", update.TaskID, "status", update.Status.String()) + } + + return nil +} + // GetGoal returns the current goal func (m *Manager) GetGoal() *Goal { m.mu.RLock() @@ -211,3 +233,9 @@ type TaskInput struct { Title string `json:"title"` Description string `json:"description"` } + +// TaskUpdate represents a task status update +type TaskUpdate struct { + TaskID string `json:"task_id"` + Status TaskStatus `json:"status"` +}