diff --git a/go.mod b/go.mod index 274d86146d9460ab9eaae6523d8654f833a3ab34..fd90570684bc6f8a71935e1fd59fba8e6ff8d264 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.24.3 require ( github.com/charmbracelet/fang v0.3.0 + github.com/go-playground/validator/v10 v10.27.0 github.com/mark3labs/mcp-go v0.36.0 github.com/spf13/cobra v1.9.1 github.com/spf13/viper v1.20.1 @@ -19,10 +20,14 @@ require ( github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444 // indirect github.com/charmbracelet/x/term v0.2.1 // indirect github.com/fsnotify/fsnotify v1.8.0 // indirect + github.com/gabriel-vasile/mimetype v1.4.8 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-viper/mapstructure/v2 v2.2.1 // indirect github.com/google/uuid v1.6.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/invopop/jsonschema v0.13.0 // indirect + github.com/leodido/go-urn v1.4.0 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mailru/easyjson v0.7.7 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect @@ -44,6 +49,8 @@ require ( github.com/yosida95/uritemplate/v3 v3.0.2 // indirect go.uber.org/atomic v1.9.0 // indirect go.uber.org/multierr v1.9.0 // indirect + golang.org/x/crypto v0.33.0 // indirect + golang.org/x/net v0.34.0 // indirect golang.org/x/sys v0.33.0 // indirect golang.org/x/text v0.24.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/go.sum b/go.sum index de1012281aec02536d72138034ec3c39f1af322c..f74a2fb555253dbaa8f47d4779c924ef2dbaba93 100644 --- a/go.sum +++ b/go.sum @@ -28,6 +28,16 @@ github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHk github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M= github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM= +github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4= +github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo= github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss= github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= @@ -43,6 +53,8 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= @@ -101,8 +113,12 @@ go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE= go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI= go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ= +golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus= +golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M= golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= +golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0= +golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k= golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= diff --git a/internal/mcp/server.go b/internal/mcp/server.go index 58e9658146c7a84d455a40087edb58f721c711c2..6a1f5663ef02071add4c7a74e14da0e5e70712bf 100644 --- a/internal/mcp/server.go +++ b/internal/mcp/server.go @@ -165,25 +165,19 @@ func (s *Server) registerTools(mcpServer *server.MCPServer) { func (s *Server) handleSetGoal(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { s.logger.Info("Received project_management__set_goal tool call") - // Extract parameters - arguments := request.GetArguments() - title, ok := arguments["title"].(string) - if !ok || title == "" { - return createErrorResult("Error: title parameter is required and must be a string"), nil - } - - description, ok := arguments["description"].(string) - if !ok { - return createErrorResult("Error: description parameter is required and must be a string"), nil + // Parse and validate request + var req SetGoalRequest + if err := parseAndValidate(request.GetArguments(), &req); err != nil { + return createErrorResult(fmt.Sprintf("Invalid request: %v", err)), nil } // Set goal - if err := s.planner.SetGoal(title, description); err != nil { + if err := s.planner.SetGoal(req.Title, req.Description); err != nil { s.logger.Error("Failed to set goal", "error", err) return createErrorResult(fmt.Sprintf("Error setting goal: %v", err)), nil } - goalText := formatGoalText(title, description) + goalText := formatGoalText(req.Title, req.Description) response := fmt.Sprintf("Goal \"%s\" saved! You probably want to add one or more tasks now.", goalText) return createSuccessResult(response), nil } @@ -192,31 +186,20 @@ func (s *Server) handleSetGoal(ctx context.Context, request mcp.CallToolRequest) func (s *Server) handleChangeGoal(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { s.logger.Info("Received project_management__change_goal tool call") - // Extract parameters - arguments := request.GetArguments() - title, ok := arguments["title"].(string) - if !ok || title == "" { - return createErrorResult("Error: title parameter is required and must be a string"), nil - } - - description, ok := arguments["description"].(string) - if !ok { - return createErrorResult("Error: description parameter is required and must be a string"), nil - } - - reason, ok := arguments["reason"].(string) - if !ok || reason == "" { - return createErrorResult("Error: reason parameter is required and must be a string"), nil + // Parse and validate request + var req ChangeGoalRequest + if err := parseAndValidate(request.GetArguments(), &req); err != nil { + return createErrorResult(fmt.Sprintf("Invalid request: %v", err)), nil } // Change goal - if err := s.planner.ChangeGoal(title, description, reason); err != nil { + if err := s.planner.ChangeGoal(req.Title, req.Description, req.Reason); err != nil { s.logger.Error("Failed to change goal", "error", err) return createErrorResult(fmt.Sprintf("Error changing goal: %v", err)), nil } - goalText := formatGoalText(title, description) - response := fmt.Sprintf("Goal changed to \"%s\" (reason: %s). You probably want to add one or more tasks now.", goalText, reason) + goalText := formatGoalText(req.Title, req.Description) + response := fmt.Sprintf("Goal changed to \"%s\" (reason: %s). You probably want to add one or more tasks now.", goalText, req.Reason) return createSuccessResult(response), nil } @@ -224,37 +207,18 @@ func (s *Server) handleChangeGoal(ctx context.Context, request mcp.CallToolReque func (s *Server) handleAddTasks(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { s.logger.Info("Received project_management__add_tasks tool call") - // Extract parameters - arguments := request.GetArguments() - tasksRaw, ok := arguments["tasks"] - if !ok { - return createErrorResult("Error: tasks parameter is required"), nil - } - - // Convert to slice of interfaces - tasksSlice, ok := tasksRaw.([]any) - if !ok { - return createErrorResult("Error: tasks parameter must be an array"), nil + // Parse and validate request + var req AddTasksRequest + if err := parseAndValidate(request.GetArguments(), &req); err != nil { + return createErrorResult(fmt.Sprintf("Invalid request: %v", err)), nil } - // Parse tasks - tasks := make([]planning.TaskInput, 0, len(tasksSlice)) - for _, taskRaw := range tasksSlice { - taskMap, ok := taskRaw.(map[string]any) - if !ok { - return createErrorResult("Error: each task must be an object"), nil - } - - title, ok := taskMap["title"].(string) - if !ok || title == "" { - return createErrorResult("Error: each task must have a non-empty title"), nil - } - - description, _ := taskMap["description"].(string) - + // Convert MCP task inputs to planning task inputs + tasks := make([]planning.TaskInput, 0, len(req.Tasks)) + for _, mcpTask := range req.Tasks { tasks = append(tasks, planning.TaskInput{ - Title: title, - Description: description, + Title: mcpTask.Title, + Description: mcpTask.Description, }) } @@ -288,9 +252,14 @@ func (s *Server) handleAddTasks(ctx context.Context, request mcp.CallToolRequest func (s *Server) handleGetTasks(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { s.logger.Info("Received project_management__get_tasks tool call") - // Extract parameters - arguments := request.GetArguments() - statusFilter, _ := arguments["status"].(string) + // Parse and validate request + var req GetTasksRequest + if err := parseAndValidate(request.GetArguments(), &req); err != nil { + return createErrorResult(fmt.Sprintf("Invalid request: %v", err)), nil + } + + // Default status to "all" if empty + statusFilter := req.Status if statusFilter == "" { statusFilter = "all" } @@ -309,47 +278,18 @@ func (s *Server) handleGetTasks(ctx context.Context, request mcp.CallToolRequest func (s *Server) handleUpdateTaskStatuses(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { s.logger.Info("Received project_management__update_task_statuses tool call") - // Extract parameters - arguments := request.GetArguments() - tasksRaw, ok := arguments["tasks"] - if !ok { - return createErrorResult("Error: tasks parameter is required"), nil - } - - // Convert to slice of interfaces - tasksSlice, ok := tasksRaw.([]any) - if !ok { - return createErrorResult("Error: tasks parameter must be an array"), nil + // Parse and validate request + var req UpdateTaskStatusesRequest + if err := parseAndValidate(request.GetArguments(), &req); err != nil { + return createErrorResult(fmt.Sprintf("Invalid request: %v", err)), nil } - if len(tasksSlice) == 0 { - return createErrorResult("Error: at least one task update is required"), nil - } - - // Parse task updates - updates := make([]planning.TaskUpdate, 0, len(tasksSlice)) - for _, taskRaw := range tasksSlice { - taskMap, ok := taskRaw.(map[string]any) - if !ok { - return createErrorResult("Error: each task update must be an object"), nil - } - - taskID, ok := taskMap["task_id"].(string) - if !ok || taskID == "" { - return createErrorResult("Error: each task update must have a non-empty task_id"), nil - } - - statusStr, ok := taskMap["status"].(string) - if !ok || statusStr == "" { - return createErrorResult("Error: each task update must have a non-empty status"), nil - } - - // Parse status - status := planning.ParseStatus(statusStr) - + // Convert MCP task update inputs to planning task updates + updates := make([]planning.TaskUpdate, 0, len(req.Tasks)) + for _, mcpUpdate := range req.Tasks { updates = append(updates, planning.TaskUpdate{ - TaskID: taskID, - Status: status, + TaskID: mcpUpdate.TaskID, + Status: planning.ParseStatus(mcpUpdate.Status), }) } @@ -368,35 +308,14 @@ func (s *Server) handleUpdateTaskStatuses(ctx context.Context, request mcp.CallT func (s *Server) handleDeleteTasks(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { s.logger.Info("Received project_management__delete_tasks tool call") - // Extract parameters - arguments := request.GetArguments() - taskIDsRaw, ok := arguments["task_ids"] - if !ok { - return createErrorResult("Error: task_ids parameter is required"), nil - } - - // Convert to slice of interfaces - taskIDsSlice, ok := taskIDsRaw.([]any) - if !ok { - return createErrorResult("Error: task_ids parameter must be an array"), nil - } - - if len(taskIDsSlice) == 0 { - return createErrorResult("Error: at least one task ID is required"), nil - } - - // Parse task IDs - taskIDs := make([]string, 0, len(taskIDsSlice)) - for _, taskIDRaw := range taskIDsSlice { - taskID, ok := taskIDRaw.(string) - if !ok || taskID == "" { - return createErrorResult("Error: each task ID must be a non-empty string"), nil - } - taskIDs = append(taskIDs, taskID) + // Parse and validate request + var req DeleteTasksRequest + if err := parseAndValidate(request.GetArguments(), &req); err != nil { + return createErrorResult(fmt.Sprintf("Invalid request: %v", err)), nil } // Delete tasks - if err := s.planner.DeleteTasks(taskIDs); err != nil { + if err := s.planner.DeleteTasks(req.TaskIDs); err != nil { s.logger.Error("Failed to delete tasks", "error", err) return createErrorResult(fmt.Sprintf("Error deleting tasks: %v", err)), nil } diff --git a/internal/mcp/types.go b/internal/mcp/types.go new file mode 100644 index 0000000000000000000000000000000000000000..6d3941f753684ef5c820b5aecd116ee4dd1b8358 --- /dev/null +++ b/internal/mcp/types.go @@ -0,0 +1,88 @@ +// SPDX-FileCopyrightText: Amolith +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +package mcp + +import ( + "encoding/json" + "fmt" + + "github.com/go-playground/validator/v10" +) + +// Goal management requests + +// SetGoalRequest represents the request structure for project_management__set_goal +type SetGoalRequest struct { + Title string `json:"title" validate:"required"` + Description string `json:"description" validate:"required"` +} + +// ChangeGoalRequest represents the request structure for project_management__change_goal +type ChangeGoalRequest struct { + Title string `json:"title" validate:"required"` + Description string `json:"description" validate:"required"` + Reason string `json:"reason" validate:"required"` +} + +// Task management requests + +// AddTasksRequest represents the request structure for project_management__add_tasks +type AddTasksRequest struct { + Tasks []MCPTaskInput `json:"tasks" validate:"required,min=1"` +} + +// MCPTaskInput represents a single task input for adding tasks +type MCPTaskInput struct { + Title string `json:"title" validate:"required"` + Description string `json:"description"` +} + +// GetTasksRequest represents the request structure for project_management__get_tasks +type GetTasksRequest struct { + Status string `json:"status,omitempty"` +} + +// UpdateTaskStatusesRequest represents the request structure for project_management__update_task_statuses +type UpdateTaskStatusesRequest struct { + Tasks []MCPTaskUpdateInput `json:"tasks" validate:"required,min=1"` +} + +// MCPTaskUpdateInput represents a single task update input +type MCPTaskUpdateInput struct { + TaskID string `json:"task_id" validate:"required"` + Status string `json:"status" validate:"required,oneof=pending in_progress completed cancelled failed"` +} + +// DeleteTasksRequest represents the request structure for project_management__delete_tasks +type DeleteTasksRequest struct { + TaskIDs []string `json:"task_ids" validate:"required,min=1"` +} + +// validator instance for struct validation +var validate *validator.Validate + +func init() { + validate = validator.New() +} + +// parseAndValidate is a generic helper function to parse map[string]any to struct and validate +func parseAndValidate[T any](arguments map[string]any, dest *T) error { + // Convert map to JSON then unmarshal to struct + jsonData, err := json.Marshal(arguments) + if err != nil { + return fmt.Errorf("failed to marshal arguments: %w", err) + } + + if err := json.Unmarshal(jsonData, dest); err != nil { + return fmt.Errorf("failed to unmarshal request: %w", err) + } + + // Validate the struct + if err := validate.Struct(dest); err != nil { + return fmt.Errorf("validation failed: %w", err) + } + + return nil +}