refactor: update mcp-go to v0.40.0 and use typed

Amolith and Crush created

handlers

- Upgrade mcp-go dependency from v0.23.1 to v0.40.0
- Replace direct handler functions with typed handlers
  using mcp.NewTypedToolHandler
- Add proper argument structs for all tool handlers
- Improve type safety and reduce runtime type assertions

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

Change summary

cmd/lunatask-mcp-server.go |  10 
go.mod                     |   8 
go.sum                     |  17 +
tools/habits.go            |  18 +
tools/tasks.go             | 302 +++++++++++++++++----------------------
tools/tools.go             |  12 +
6 files changed, 181 insertions(+), 186 deletions(-)

Detailed changes

cmd/lunatask-mcp-server.go 🔗

@@ -202,7 +202,7 @@ func NewMCPServer(appConfig *Config) *server.MCPServer {
 			mcp.Description("Natural language date expression. Examples: 'tomorrow', '1 week', 'sunday at 19:00', 'january 15 at 2pm', 'next friday', 'now'. The tool will parse this into a properly formatted timestamp for use with task scheduling."),
 			mcp.Required(),
 		),
-	), toolHandlers.HandleGetTimestamp)
+	), mcp.NewTypedToolHandler(toolHandlers.HandleGetTimestamp))
 
 	mcpServer.AddTool(
 		mcp.NewTool(
@@ -252,7 +252,7 @@ func NewMCPServer(appConfig *Config) *server.MCPServer {
 		mcp.WithString("scheduled_on",
 			mcp.Description("Scheduled date/time for the task. Must use the formatted timestamp returned by get_timestamp tool. Only include if user specifies when the task should be done."),
 		),
-	), toolHandlers.HandleCreateTask)
+	), mcp.NewTypedToolHandler(toolHandlers.HandleCreateTask))
 
 	mcpServer.AddTool(mcp.NewTool("update_task",
 		mcp.WithDescription("Updates an existing task. Only provided fields will be updated. WORKFLOW: Use list_areas_and_goals first if changing area/goal, then get_timestamp if changing schedule. Only include parameters that are being changed. Empty strings will clear existing values for text fields."),
@@ -297,7 +297,7 @@ func NewMCPServer(appConfig *Config) *server.MCPServer {
 		mcp.WithString("scheduled_on",
 			mcp.Description("New scheduled date/time for the task. Must use the formatted timestamp returned by get_timestamp tool. Sending an empty string might clear the scheduled date. Only include if changing the schedule."),
 		),
-	), toolHandlers.HandleUpdateTask)
+	), mcp.NewTypedToolHandler(toolHandlers.HandleUpdateTask))
 
 	mcpServer.AddTool(mcp.NewTool("delete_task",
 		mcp.WithDescription("Permanently deletes an existing task from Lunatask. This action cannot be undone."),
@@ -305,7 +305,7 @@ func NewMCPServer(appConfig *Config) *server.MCPServer {
 			mcp.Description("ID of the task to delete. This must be a valid task ID from an existing task in Lunatask."),
 			mcp.Required(),
 		),
-	), toolHandlers.HandleDeleteTask)
+	), mcp.NewTypedToolHandler(toolHandlers.HandleDeleteTask))
 
 	mcpServer.AddTool(
 		mcp.NewTool(
@@ -325,7 +325,7 @@ func NewMCPServer(appConfig *Config) *server.MCPServer {
 			mcp.Description("Timestamp when the habit was performed. Must use the formatted timestamp returned by get_timestamp tool. Examples: if user says 'I did this yesterday', use get_timestamp with 'yesterday'."),
 			mcp.Required(),
 		),
-	), toolHandlers.HandleTrackHabitActivity)
+	), mcp.NewTypedToolHandler(toolHandlers.HandleTrackHabitActivity))
 
 	return mcpServer
 }

go.mod 🔗

@@ -10,19 +10,25 @@ require (
 	github.com/BurntSushi/toml v1.5.0
 	github.com/go-playground/validator/v10 v10.27.0
 	github.com/ijt/go-anytime v1.9.2
-	github.com/mark3labs/mcp-go v0.23.1
+	github.com/mark3labs/mcp-go v0.40.0
 )
 
 require (
+	github.com/bahlo/generic-list-go v0.2.0 // indirect
+	github.com/buger/jsonparser v1.1.1 // indirect
 	github.com/gabriel-vasile/mimetype v1.4.10 // indirect
 	github.com/go-playground/locales v0.14.1 // indirect
 	github.com/go-playground/universal-translator v0.18.1 // indirect
 	github.com/google/uuid v1.6.0 // indirect
 	github.com/ijt/goparsify v0.0.0-20221203142333-3a5276334b8d // indirect
+	github.com/invopop/jsonschema v0.13.0 // indirect
 	github.com/leodido/go-urn v1.4.0 // indirect
+	github.com/mailru/easyjson v0.7.7 // indirect
 	github.com/spf13/cast v1.7.1 // indirect
+	github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect
 	github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
 	golang.org/x/crypto v0.42.0 // indirect
 	golang.org/x/sys v0.36.0 // indirect
 	golang.org/x/text v0.29.0 // indirect
+	gopkg.in/yaml.v3 v3.0.1 // indirect
 )

go.sum 🔗

@@ -1,5 +1,9 @@
 github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=
 github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
+github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
+github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
+github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs=
+github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
 github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
@@ -22,14 +26,19 @@ github.com/ijt/go-anytime v1.9.2 h1:DmYgVwUiFPNR+n6c1T5P070tlGATRZG4aYNJs6XDUfU=
 github.com/ijt/go-anytime v1.9.2/go.mod h1:egBT6FhVjNlXNHUN2wTPi6ILCNKXeeXFy04pWJjw/LI=
 github.com/ijt/goparsify v0.0.0-20221203142333-3a5276334b8d h1:LFOmpWrSbtolg0YqYC9hQjj5WSLtRGb6aZ3JAugLfgg=
 github.com/ijt/goparsify v0.0.0-20221203142333-3a5276334b8d/go.mod h1:112TOyA+aruNSUBlyBWlKBdLVYTdhjiO2CKD0j/URSU=
+github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E=
+github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0=
+github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
 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/mark3labs/mcp-go v0.23.1 h1:RzTzZ5kJ+HxwnutKA4rll8N/pKV6Wh5dhCmiJUu5S9I=
-github.com/mark3labs/mcp-go v0.23.1/go.mod h1:rXqOudj/djTORU/ThxYx8fqEVj/5pvTuuebQ2RC7uk4=
+github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
+github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
+github.com/mark3labs/mcp-go v0.40.0 h1:M0oqK412OHBKut9JwXSsj4KanSmEKpzoW8TcxoPOkAU=
+github.com/mark3labs/mcp-go v0.40.0/go.mod h1:T7tUa2jO6MavG+3P25Oy/jR7iCeJPHImCZHRymCn39g=
 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
 github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
@@ -40,6 +49,8 @@ github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsT
 github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
 github.com/tj/assert v0.0.0-20190920132354-ee03d75cd160 h1:NSWpaDaurcAJY7PkL8Xt0PhZE7qpvbZl5ljd8r6U0bI=
 github.com/tj/assert v0.0.0-20190920132354-ee03d75cd160/go.mod h1:mZ9/Rh9oLWpLLDRpvE+3b7gP/C2YyLFYxNmcLnPTMe0=
+github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc=
+github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw=
 github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
 github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
 golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI=
@@ -48,5 +59,7 @@ golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
 golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
 golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
 golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
 gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

tools/habits.go 🔗

@@ -29,24 +29,28 @@ func (h *Handlers) HandleListHabitsAndActivities(ctx context.Context, request mc
 	}, nil
 }
 
+// TrackHabitActivityArgs defines the arguments for track_habit_activity tool call.
+type TrackHabitActivityArgs struct {
+	HabitID     string `json:"habit_id"`
+	PerformedOn string `json:"performed_on"`
+}
+
 // HandleTrackHabitActivity handles the track_habit_activity tool call.
-func (h *Handlers) HandleTrackHabitActivity(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-	habitID, ok := request.Params.Arguments["habit_id"].(string)
-	if !ok || habitID == "" {
+func (h *Handlers) HandleTrackHabitActivity(ctx context.Context, request mcp.CallToolRequest, args TrackHabitActivityArgs) (*mcp.CallToolResult, error) {
+	if args.HabitID == "" {
 		return reportMCPError("Missing or invalid required argument: habit_id")
 	}
 
-	performedOn, ok := request.Params.Arguments["performed_on"].(string)
-	if !ok || performedOn == "" {
+	if args.PerformedOn == "" {
 		return reportMCPError("Missing or invalid required argument: performed_on")
 	}
 
 	client := lunatask.NewClient(h.config.AccessToken)
 	habitRequest := &lunatask.TrackHabitActivityRequest{
-		PerformedOn: performedOn,
+		PerformedOn: args.PerformedOn,
 	}
 
-	resp, err := client.TrackHabitActivity(ctx, habitID, habitRequest)
+	resp, err := client.TrackHabitActivity(ctx, args.HabitID, habitRequest)
 	if err != nil {
 		return reportMCPError(fmt.Sprintf("Failed to track habit activity: %v", err))
 	}

tools/tasks.go 🔗

@@ -15,22 +15,53 @@ import (
 	"github.com/mark3labs/mcp-go/mcp"
 )
 
-// HandleCreateTask handles the create_task tool call.
-func (h *Handlers) HandleCreateTask(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-	arguments := request.Params.Arguments
+// CreateTaskArgs defines the arguments for create_task tool call.
+type CreateTaskArgs struct {
+	AreaID      string  `json:"area_id"`
+	GoalID      string  `json:"goal_id,omitempty"`
+	Name        string  `json:"name"`
+	Note        string  `json:"note,omitempty"`
+	Estimate    float64 `json:"estimate,omitempty"`
+	Priority    string  `json:"priority,omitempty"`
+	Eisenhower  string  `json:"eisenhower,omitempty"`
+	Motivation  string  `json:"motivation,omitempty"`
+	Status      string  `json:"status,omitempty"`
+	ScheduledOn string  `json:"scheduled_on,omitempty"`
+}
+
+// UpdateTaskArgs defines the arguments for update_task tool call.
+type UpdateTaskArgs struct {
+	TaskID      string  `json:"task_id"`
+	AreaID      string  `json:"area_id,omitempty"`
+	GoalID      string  `json:"goal_id,omitempty"`
+	Name        string  `json:"name"`
+	Note        string  `json:"note,omitempty"`
+	Estimate    float64 `json:"estimate,omitempty"`
+	Priority    string  `json:"priority,omitempty"`
+	Eisenhower  string  `json:"eisenhower,omitempty"`
+	Motivation  string  `json:"motivation,omitempty"`
+	Status      string  `json:"status,omitempty"`
+	ScheduledOn string  `json:"scheduled_on,omitempty"`
+}
+
+// DeleteTaskArgs defines the arguments for delete_task tool call.
+type DeleteTaskArgs struct {
+	TaskID string `json:"task_id"`
+}
 
+// HandleCreateTask handles the create_task tool call.
+func (h *Handlers) HandleCreateTask(ctx context.Context, request mcp.CallToolRequest, args CreateTaskArgs) (*mcp.CallToolResult, error) {
 	if _, err := LoadLocation(h.config.Timezone); err != nil {
 		return reportMCPError(err.Error())
 	}
 
-	areaID, ok := arguments["area_id"].(string)
-	if !ok || areaID == "" {
+	if args.AreaID == "" {
 		return reportMCPError("Missing or invalid required argument: area_id")
 	}
 
 	var areaFoundProvider AreaProvider
 	for _, ap := range h.config.Areas {
-		if ap.GetID() == areaID {
+		if ap.GetID() == args.AreaID {
 			areaFoundProvider = ap
 			break
 		}
@@ -39,10 +70,10 @@ func (h *Handlers) HandleCreateTask(ctx context.Context, request mcp.CallToolReq
 		return reportMCPError("Area not found for given area_id")
 	}
 
-	if goalID, exists := arguments["goal_id"].(string); exists && goalID != "" {
+	if args.GoalID != "" {
 		found := false
 		for _, goal := range areaFoundProvider.GetGoals() {
-			if goal.GetID() == goalID {
+			if goal.GetID() == args.GoalID {
 				found = true
 				break
 			}
@@ -52,6 +83,20 @@ func (h *Handlers) HandleCreateTask(ctx context.Context, request mcp.CallToolReq
 		}
 	}
 
+	// Create a map to store the final arguments for JSON marshaling
+	finalArgs := make(map[string]any)
+	finalArgs["area_id"] = args.AreaID
+	if args.GoalID != "" {
+		finalArgs["goal_id"] = args.GoalID
+	}
+	finalArgs["name"] = args.Name
+	if args.Note != "" {
+		finalArgs["note"] = args.Note
+	}
+	if args.Estimate > 0 {
+		finalArgs["estimate"] = int(args.Estimate)
+	}
+
 	priorityMap := map[string]int{
 		"lowest":  -2,
 		"low":     -1,
@@ -60,16 +105,12 @@ func (h *Handlers) HandleCreateTask(ctx context.Context, request mcp.CallToolReq
 		"highest": 2,
 	}
 
-	if priorityArg, exists := arguments["priority"]; exists && priorityArg != nil {
-		priorityStr, ok := priorityArg.(string)
-		if !ok {
-			return reportMCPError("Invalid type for 'priority' argument: expected string.")
-		}
-		translatedPriority, isValid := priorityMap[strings.ToLower(priorityStr)]
+	if args.Priority != "" {
+		translatedPriority, isValid := priorityMap[strings.ToLower(args.Priority)]
 		if !isValid {
-			return reportMCPError(fmt.Sprintf("Invalid 'priority' value: '%s'. Must be one of 'lowest', 'low', 'neutral', 'high', 'highest'.", priorityStr))
+			return reportMCPError(fmt.Sprintf("Invalid 'priority' value: '%s'. Must be one of 'lowest', 'low', 'neutral', 'high', 'highest'.", args.Priority))
 		}
-		arguments["priority"] = translatedPriority
+		finalArgs["priority"] = translatedPriority
 	}
 
 	eisenhowerMap := map[string]int{
@@ -80,57 +121,40 @@ func (h *Handlers) HandleCreateTask(ctx context.Context, request mcp.CallToolReq
 		"neither urgent nor important": 4,
 	}
 
-	if eisenhowerArg, exists := arguments["eisenhower"]; exists && eisenhowerArg != nil {
-		eisenhowerStr, ok := eisenhowerArg.(string)
-		if !ok {
-			return reportMCPError("Invalid type for 'eisenhower' argument: expected string.")
-		}
-		translatedEisenhower, isValid := eisenhowerMap[strings.ToLower(eisenhowerStr)]
+	if args.Eisenhower != "" {
+		translatedEisenhower, isValid := eisenhowerMap[strings.ToLower(args.Eisenhower)]
 		if !isValid {
-			return reportMCPError(fmt.Sprintf("Invalid 'eisenhower' value: '%s'. Must be one of 'uncategorised', 'both urgent and important', 'urgent, but not important', 'important, but not urgent', 'neither urgent nor important'.", eisenhowerStr))
+			return reportMCPError(fmt.Sprintf("Invalid 'eisenhower' value: '%s'. Must be one of 'uncategorised', 'both urgent and important', 'urgent, but not important', 'important, but not urgent', 'neither urgent nor important'.", args.Eisenhower))
 		}
-		arguments["eisenhower"] = translatedEisenhower
+		finalArgs["eisenhower"] = translatedEisenhower
 	}
 
-	if motivationVal, exists := arguments["motivation"]; exists && motivationVal != nil {
-		if motivation, ok := motivationVal.(string); ok && motivation != "" {
-			validMotivations := map[string]bool{"must": true, "should": true, "want": true}
-			if !validMotivations[motivation] {
-				return reportMCPError("'motivation' must be one of 'must', 'should', or 'want'")
-			}
-		} else if ok {
-			// empty string is allowed
-		} else {
-			return reportMCPError("'motivation' must be a string")
+	if args.Motivation != "" {
+		validMotivations := map[string]bool{"must": true, "should": true, "want": true}
+		if !validMotivations[args.Motivation] {
+			return reportMCPError("'motivation' must be one of 'must', 'should', or 'want'")
 		}
+		finalArgs["motivation"] = args.Motivation
 	}
 
-	if statusVal, exists := arguments["status"]; exists && statusVal != nil {
-		if status, ok := statusVal.(string); ok && status != "" {
-			validStatus := map[string]bool{"later": true, "next": true, "started": true, "waiting": true, "completed": true}
-			if !validStatus[status] {
-				return reportMCPError("'status' must be one of 'later', 'next', 'started', 'waiting', or 'completed'")
-			}
-		} else if ok {
-			// empty string is allowed
-		} else {
-			return reportMCPError("'status' must be a string")
+	if args.Status != "" {
+		validStatus := map[string]bool{"later": true, "next": true, "started": true, "waiting": true, "completed": true}
+		if !validStatus[args.Status] {
+			return reportMCPError("'status' must be one of 'later', 'next', 'started', 'waiting', or 'completed'")
 		}
+		finalArgs["status"] = args.Status
 	}
 
-	if scheduledOnArg, exists := arguments["scheduled_on"]; exists {
-		if scheduledOnStr, ok := scheduledOnArg.(string); ok && scheduledOnStr != "" {
-			if _, err := time.Parse(time.RFC3339, scheduledOnStr); err != nil {
-				return reportMCPError(fmt.Sprintf("Invalid format for scheduled_on: '%s'. Must be RFC3339 timestamp (e.g., YYYY-MM-DDTHH:MM:SSZ). Use get_timestamp tool first.", scheduledOnStr))
-			}
-		} else if !ok {
-			return reportMCPError("Invalid type for scheduled_on argument: expected string.")
+	if args.ScheduledOn != "" {
+		if _, err := time.Parse(time.RFC3339, args.ScheduledOn); err != nil {
+			return reportMCPError(fmt.Sprintf("Invalid format for scheduled_on: '%s'. Must be RFC3339 timestamp (e.g., YYYY-MM-DDTHH:MM:SSZ). Use get_timestamp tool first.", args.ScheduledOn))
 		}
+		finalArgs["scheduled_on"] = args.ScheduledOn
 	}
 
 	client := lunatask.NewClient(h.config.AccessToken)
 	var task lunatask.CreateTaskRequest
-	argBytes, err := json.Marshal(arguments)
+	argBytes, err := json.Marshal(finalArgs)
 	if err != nil {
 		return reportMCPError(fmt.Sprintf("Failed to process arguments: %v", err))
 	}
@@ -165,11 +189,8 @@ func (h *Handlers) HandleCreateTask(ctx context.Context, request mcp.CallToolReq
 }
 
 // HandleUpdateTask handles the update_task tool call.
-func (h *Handlers) HandleUpdateTask(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-	arguments := request.Params.Arguments
-
-	taskID, ok := arguments["task_id"].(string)
-	if !ok || taskID == "" {
+func (h *Handlers) HandleUpdateTask(ctx context.Context, request mcp.CallToolRequest, args UpdateTaskArgs) (*mcp.CallToolResult, error) {
+	if args.TaskID == "" {
 		return reportMCPError("Missing or invalid required argument: task_id")
 	}
 
@@ -182,80 +203,50 @@ func (h *Handlers) HandleUpdateTask(ctx context.Context, request mcp.CallToolReq
 	var specifiedAreaProvider AreaProvider
 	areaIDProvided := false
 
-	if areaIDArg, exists := arguments["area_id"]; exists {
-		if areaIDStr, ok := areaIDArg.(string); ok && areaIDStr != "" {
-			updatePayload.AreaID = areaIDStr
-			areaIDProvided = true
-			found := false
-			for _, ap := range h.config.Areas {
-				if ap.GetID() == areaIDStr {
-					specifiedAreaProvider = ap
-					found = true
-					break
-				}
-			}
-			if !found {
-				return reportMCPError(fmt.Sprintf("Area not found for given area_id: %s", areaIDStr))
+	if args.AreaID != "" {
+		updatePayload.AreaID = args.AreaID
+		areaIDProvided = true
+		found := false
+		for _, ap := range h.config.Areas {
+			if ap.GetID() == args.AreaID {
+				specifiedAreaProvider = ap
+				found = true
+				break
 			}
-		} else if !ok && areaIDArg != nil {
-			return reportMCPError("Invalid type for area_id argument: expected string.")
 		}
-		// If area_id is not provided or is empty, we don't set it in the updatePayload
-		// This will leave the task in its current area
-	}
-
-	if goalIDArg, exists := arguments["goal_id"]; exists {
-		if goalIDStr, ok := goalIDArg.(string); ok && goalIDStr != "" {
-			updatePayload.GoalID = goalIDStr
-			if specifiedAreaProvider != nil {
-				foundGoal := false
-				for _, goal := range specifiedAreaProvider.GetGoals() {
-					if goal.GetID() == goalIDStr {
-						foundGoal = true
-						break
-					}
-				}
-				if !foundGoal {
-					return reportMCPError(fmt.Sprintf("Goal not found in specified area '%s' for given goal_id: %s", specifiedAreaProvider.GetName(), goalIDStr))
-				}
-			} else if areaIDProvided {
-				return reportMCPError("Internal error: area_id provided but area details not loaded for goal validation.")
-			}
-			// If area_id is not provided, we're not moving the task to a different area
-			// In this case, the goal validation should be skipped as we don't know the current area
-		} else if !ok && goalIDArg != nil {
-			return reportMCPError("Invalid type for goal_id argument: expected string.")
+		if !found {
+			return reportMCPError(fmt.Sprintf("Area not found for given area_id: %s", args.AreaID))
 		}
 	}
 
-	nameArg := arguments["name"]
-	if nameStr, ok := nameArg.(string); ok {
-		updatePayload.Name = nameStr
-	} else {
-		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 args.GoalID != "" {
+		updatePayload.GoalID = args.GoalID
+		if specifiedAreaProvider != nil {
+			foundGoal := false
+			for _, goal := range specifiedAreaProvider.GetGoals() {
+				if goal.GetID() == args.GoalID {
+					foundGoal = true
+					break
+				}
+			}
+			if !foundGoal {
+				return reportMCPError(fmt.Sprintf("Goal not found in specified area '%s' for given goal_id: %s", specifiedAreaProvider.GetName(), args.GoalID))
+			}
+		} else if areaIDProvided {
+			return reportMCPError("Internal error: area_id provided but area details not loaded for goal validation.")
 		}
+		// If area_id is not provided, we're not moving the task to a different area
+		// In this case, the goal validation should be skipped as we don't know the current area
 	}
 
-	if estimateArg, exists := arguments["estimate"]; exists && estimateArg != nil {
-		if estimateVal, ok := estimateArg.(float64); ok {
-			updatePayload.Estimate = int(estimateVal)
-		} else {
-			return reportMCPError("Invalid type for estimate argument: expected number.")
-		}
+	updatePayload.Name = args.Name
+	updatePayload.Note = args.Note
+
+	if args.Estimate > 0 {
+		updatePayload.Estimate = int(args.Estimate)
 	}
 
-	if priorityArg, exists := arguments["priority"]; exists && priorityArg != nil {
-		priorityStr, ok := priorityArg.(string)
-		if !ok {
-			return reportMCPError("Invalid type for 'priority' argument: expected string.")
-		}
+	if args.Priority != "" {
 		priorityMap := map[string]int{
 			"lowest":  -2,
 			"low":     -1,
@@ -263,18 +254,14 @@ func (h *Handlers) HandleUpdateTask(ctx context.Context, request mcp.CallToolReq
 			"high":    1,
 			"highest": 2,
 		}
-		translatedPriority, isValid := priorityMap[strings.ToLower(priorityStr)]
+		translatedPriority, isValid := priorityMap[strings.ToLower(args.Priority)]
 		if !isValid {
-			return reportMCPError(fmt.Sprintf("Invalid 'priority' value: '%s'. Must be one of 'lowest', 'low', 'neutral', 'high', 'highest'.", priorityStr))
+			return reportMCPError(fmt.Sprintf("Invalid 'priority' value: '%s'. Must be one of 'lowest', 'low', 'neutral', 'high', 'highest'.", args.Priority))
 		}
 		updatePayload.Priority = translatedPriority
 	}
 
-	if eisenhowerArg, exists := arguments["eisenhower"]; exists && eisenhowerArg != nil {
-		eisenhowerStr, ok := eisenhowerArg.(string)
-		if !ok {
-			return reportMCPError("Invalid type for 'eisenhower' argument: expected string.")
-		}
+	if args.Eisenhower != "" {
 		eisenhowerMap := map[string]int{
 			"uncategorised":                0,
 			"both urgent and important":    1,
@@ -282,56 +269,38 @@ func (h *Handlers) HandleUpdateTask(ctx context.Context, request mcp.CallToolReq
 			"important, but not urgent":    3,
 			"neither urgent nor important": 4,
 		}
-		translatedEisenhower, isValid := eisenhowerMap[strings.ToLower(eisenhowerStr)]
+		translatedEisenhower, isValid := eisenhowerMap[strings.ToLower(args.Eisenhower)]
 		if !isValid {
-			return reportMCPError(fmt.Sprintf("Invalid 'eisenhower' value: '%s'. Must be one of 'uncategorised', 'both urgent and important', 'urgent, but not important', 'important, but not urgent', 'neither urgent nor important'.", eisenhowerStr))
+			return reportMCPError(fmt.Sprintf("Invalid 'eisenhower' value: '%s'. Must be one of 'uncategorised', 'both urgent and important', 'urgent, but not important', 'important, but not urgent', 'neither urgent nor important'.", args.Eisenhower))
 		}
 		updatePayload.Eisenhower = translatedEisenhower
 	}
 
-	if motivationArg, exists := arguments["motivation"]; exists {
-		if motivationStr, ok := motivationArg.(string); ok {
-			if motivationStr != "" {
-				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 args.Motivation != "" {
+		validMotivations := map[string]bool{"must": true, "should": true, "want": true}
+		if !validMotivations[args.Motivation] {
+			return reportMCPError("'motivation' must be one of 'must', 'should', or 'want', or empty to clear.")
 		}
 	}
+	updatePayload.Motivation = args.Motivation
 
-	if statusArg, exists := arguments["status"]; exists {
-		if statusStr, ok := statusArg.(string); ok {
-			if statusStr != "" {
-				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 args.Status != "" {
+		validStatus := map[string]bool{"later": true, "next": true, "started": true, "waiting": true, "completed": true}
+		if !validStatus[args.Status] {
+			return reportMCPError("'status' must be one of 'later', 'next', 'started', 'waiting', 'completed', or empty.")
 		}
 	}
+	updatePayload.Status = args.Status
 
-	if scheduledOnArg, exists := arguments["scheduled_on"]; exists {
-		if scheduledOnStr, ok := scheduledOnArg.(string); ok {
-			if scheduledOnStr != "" {
-				if _, err := time.Parse(time.RFC3339, scheduledOnStr); err != nil {
-					return reportMCPError(fmt.Sprintf("Invalid format for scheduled_on: '%s'. Must be RFC3339. Use get_timestamp tool.", scheduledOnStr))
-				}
-			}
-			updatePayload.ScheduledOn = scheduledOnStr
-		} else if !ok && scheduledOnArg != nil {
-			return reportMCPError("Invalid type for scheduled_on argument: expected string.")
+	if args.ScheduledOn != "" {
+		if _, err := time.Parse(time.RFC3339, args.ScheduledOn); err != nil {
+			return reportMCPError(fmt.Sprintf("Invalid format for scheduled_on: '%s'. Must be RFC3339. Use get_timestamp tool.", args.ScheduledOn))
 		}
 	}
+	updatePayload.ScheduledOn = args.ScheduledOn
 
 	client := lunatask.NewClient(h.config.AccessToken)
-	response, err := client.UpdateTask(ctx, taskID, &updatePayload)
+	response, err := client.UpdateTask(ctx, args.TaskID, &updatePayload)
 	if err != nil {
 		return reportMCPError(fmt.Sprintf("Failed to update task: %v", err))
 	}
@@ -347,14 +316,13 @@ func (h *Handlers) HandleUpdateTask(ctx context.Context, request mcp.CallToolReq
 }
 
 // HandleDeleteTask handles the delete_task tool call.
-func (h *Handlers) HandleDeleteTask(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-	taskID, ok := request.Params.Arguments["task_id"].(string)
-	if !ok || taskID == "" {
+func (h *Handlers) HandleDeleteTask(ctx context.Context, request mcp.CallToolRequest, args DeleteTaskArgs) (*mcp.CallToolResult, error) {
+	if args.TaskID == "" {
 		return reportMCPError("Missing or invalid required argument: task_id")
 	}
 
 	client := lunatask.NewClient(h.config.AccessToken)
-	_, err := client.DeleteTask(ctx, taskID)
+	_, err := client.DeleteTask(ctx, args.TaskID)
 	if err != nil {
 		return reportMCPError(fmt.Sprintf("Failed to delete task: %v", err))
 	}

tools/tools.go 🔗

@@ -14,6 +14,11 @@ import (
 	"github.com/mark3labs/mcp-go/mcp"
 )
 
+// GetTimestampArgs defines the arguments for get_timestamp tool call.
+type GetTimestampArgs struct {
+	NaturalLanguageDate string `json:"natural_language_date"`
+}
+
 // AreaProvider defines the interface for accessing area data.
 type AreaProvider interface {
 	GetName() string
@@ -72,16 +77,15 @@ func LoadLocation(timezone string) (*time.Location, error) {
 }
 
 // HandleGetTimestamp handles the get_timestamp tool call.
-func (h *Handlers) HandleGetTimestamp(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-	natLangDate, ok := request.Params.Arguments["natural_language_date"].(string)
-	if !ok || natLangDate == "" {
+func (h *Handlers) HandleGetTimestamp(ctx context.Context, request mcp.CallToolRequest, args GetTimestampArgs) (*mcp.CallToolResult, error) {
+	if args.NaturalLanguageDate == "" {
 		return reportMCPError("Missing or invalid required argument: natural_language_date")
 	}
 	loc, err := LoadLocation(h.config.Timezone)
 	if err != nil {
 		return reportMCPError(err.Error())
 	}
-	parsedTime, err := anytime.Parse(natLangDate, time.Now().In(loc))
+	parsedTime, err := anytime.Parse(args.NaturalLanguageDate, time.Now().In(loc))
 	if err != nil {
 		return reportMCPError(fmt.Sprintf("Could not parse natural language date: %v", err))
 	}