feat: implement structured request/response types with validation

Amolith created

- Add internal/mcp/types.go with request structs for all MCP handlers
- Replace manual JSON parsing with parseAndValidate generic helper
- Add go-playground/validator/v10 dependency for struct validation
- Refactor all 6 MCP handlers to use structured types
- Improve type safety and eliminate ~150 lines of manual validation code
- Add compile-time type checking for all MCP request parameters

Change summary

go.mod                 |   7 +
go.sum                 |  16 ++++
internal/mcp/server.go | 169 +++++++++++--------------------------------
internal/mcp/types.go  |  88 ++++++++++++++++++++++
4 files changed, 155 insertions(+), 125 deletions(-)

Detailed changes

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

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=

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
 	}

internal/mcp/types.go 🔗

@@ -0,0 +1,88 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// 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
+}