diff --git a/cmd/lunatask-mcp-server.go b/cmd/lunatask-mcp-server.go index 983fe76cdcfb2805754dc8c451a27d14cc55ba41..a874935aa4f1f6ca4c72a38edff951fa029f79dc 100644 --- a/cmd/lunatask-mcp-server.go +++ b/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 } diff --git a/go.mod b/go.mod index ec4a64aa23d642f67ef3543a94bd20989d04f464..1ed07850b55b71f4cf608758cbe8b6cceddb9c40 100644 --- a/go.mod +++ b/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 ) diff --git a/go.sum b/go.sum index 590428de21f0be6d9ad9c5d81e272dc5ea1c863c..75555a1150ce0fb728810e6c5083feadf5981c76 100644 --- a/go.sum +++ b/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= diff --git a/tools/habits.go b/tools/habits.go index 0dcd93661f2e1976cd1d59755a5f0568dd80d696..004f412c8d24a8d61a1a0bb02912ad86e032aa33 100644 --- a/tools/habits.go +++ b/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)) } diff --git a/tools/tasks.go b/tools/tasks.go index 4678331a9aa6b81c21f3dd0af3ca6a0b7edafc8c..dc2718725128302043cf9515f3c0e23d26d4fb04 100644 --- a/tools/tasks.go +++ b/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)) } diff --git a/tools/tools.go b/tools/tools.go index 00dc914af6f8ce2e7df8a0c08dcebada985ac68b..ee724c3c91887d65e1d10b3db2716123097e9be1 100644 --- a/tools/tools.go +++ b/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)) }