1// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
2//
3// SPDX-License-Identifier: AGPL-3.0-or-later
4
5package tools
6
7import (
8 "context"
9 "encoding/json"
10 "fmt"
11 "strings"
12 "time"
13
14 "github.com/ijt/go-anytime"
15 "github.com/mark3labs/mcp-go/mcp"
16
17 "git.sr.ht/~amolith/lunatask-mcp-server/lunatask"
18)
19
20// AreaProvider defines the interface for accessing area data.
21type AreaProvider interface {
22 GetName() string
23 GetID() string
24 GetGoals() []GoalProvider
25}
26
27// GoalProvider defines the interface for accessing goal data.
28type GoalProvider interface {
29 GetName() string
30 GetID() string
31}
32
33// HabitProvider defines the interface for accessing habit data.
34type HabitProvider interface {
35 GetName() string
36 GetID() string
37}
38
39// HandlerConfig holds the necessary configuration for tool handlers.
40type HandlerConfig struct {
41 AccessToken string
42 Timezone string
43 Areas []AreaProvider
44 Habits []HabitProvider
45}
46
47// Handlers provides methods for handling MCP tool calls.
48type Handlers struct {
49 config HandlerConfig
50}
51
52// NewHandlers creates a new Handlers instance.
53func NewHandlers(config HandlerConfig) *Handlers {
54 return &Handlers{config: config}
55}
56
57// reportMCPError creates an MCP error result.
58func reportMCPError(msg string) (*mcp.CallToolResult, error) {
59 return &mcp.CallToolResult{
60 IsError: true,
61 Content: []mcp.Content{mcp.TextContent{Type: "text", Text: msg}},
62 }, nil
63}
64
65// LoadLocation loads a timezone location string, returning a *time.Location or error
66func LoadLocation(timezone string) (*time.Location, error) {
67 if timezone == "" {
68 return nil, fmt.Errorf("timezone is not configured; please set the 'timezone' value in your config file (e.g. 'UTC' or 'America/New_York')")
69 }
70 loc, err := time.LoadLocation(timezone)
71 if err != nil {
72 return nil, fmt.Errorf("could not load timezone '%s': %v", timezone, err)
73 }
74 return loc, nil
75}
76
77// HandleGetTimestamp handles the get_timestamp tool call.
78func (h *Handlers) HandleGetTimestamp(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
79 natLangDate, ok := request.Params.Arguments["natural_language_date"].(string)
80 if !ok || natLangDate == "" {
81 return reportMCPError("Missing or invalid required argument: natural_language_date")
82 }
83 loc, err := LoadLocation(h.config.Timezone)
84 if err != nil {
85 return reportMCPError(err.Error())
86 }
87 parsedTime, err := anytime.Parse(natLangDate, time.Now().In(loc))
88 if err != nil {
89 return reportMCPError(fmt.Sprintf("Could not parse natural language date: %v", err))
90 }
91 return &mcp.CallToolResult{
92 Content: []mcp.Content{
93 mcp.TextContent{
94 Type: "text",
95 Text: parsedTime.Format(time.RFC3339),
96 },
97 },
98 }, nil
99}
100
101// HandleListAreasAndGoals handles the list_areas_and_goals tool call.
102func (h *Handlers) HandleListAreasAndGoals(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
103 var b strings.Builder
104 for _, area := range h.config.Areas {
105 fmt.Fprintf(&b, "- %s: %s\n", area.GetName(), area.GetID())
106 for _, goal := range area.GetGoals() {
107 fmt.Fprintf(&b, " - %s: %s\n", goal.GetName(), goal.GetID())
108 }
109 }
110 return &mcp.CallToolResult{
111 Content: []mcp.Content{
112 mcp.TextContent{
113 Type: "text",
114 Text: b.String(),
115 },
116 },
117 }, nil
118}
119
120// HandleCreateTask handles the create_task tool call.
121func (h *Handlers) HandleCreateTask(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
122 arguments := request.Params.Arguments
123
124 if _, err := LoadLocation(h.config.Timezone); err != nil {
125 return reportMCPError(err.Error())
126 }
127
128 areaID, ok := arguments["area_id"].(string)
129 if !ok || areaID == "" {
130 return reportMCPError("Missing or invalid required argument: area_id")
131 }
132
133 var areaFoundProvider AreaProvider
134 for _, ap := range h.config.Areas {
135 if ap.GetID() == areaID {
136 areaFoundProvider = ap
137 break
138 }
139 }
140 if areaFoundProvider == nil {
141 return reportMCPError("Area not found for given area_id")
142 }
143
144 if goalID, exists := arguments["goal_id"].(string); exists && goalID != "" {
145 found := false
146 for _, goal := range areaFoundProvider.GetGoals() {
147 if goal.GetID() == goalID {
148 found = true
149 break
150 }
151 }
152 if !found {
153 return reportMCPError("Goal not found in specified area for given goal_id")
154 }
155 }
156
157 priorityMap := map[string]int{
158 "lowest": -2,
159 "low": -1,
160 "neutral": 0,
161 "high": 1,
162 "highest": 2,
163 }
164
165 if priorityArg, exists := arguments["priority"]; exists && priorityArg != nil {
166 priorityStr, ok := priorityArg.(string)
167 if !ok {
168 return reportMCPError("Invalid type for 'priority' argument: expected string.")
169 }
170 translatedPriority, isValid := priorityMap[strings.ToLower(priorityStr)]
171 if !isValid {
172 return reportMCPError(fmt.Sprintf("Invalid 'priority' value: '%s'. Must be one of 'lowest', 'low', 'neutral', 'high', 'highest'.", priorityStr))
173 }
174 arguments["priority"] = translatedPriority
175 }
176
177 if motivationVal, exists := arguments["motivation"]; exists && motivationVal != nil {
178 if motivation, ok := motivationVal.(string); ok && motivation != "" {
179 validMotivations := map[string]bool{"must": true, "should": true, "want": true}
180 if !validMotivations[motivation] {
181 return reportMCPError("'motivation' must be one of 'must', 'should', or 'want'")
182 }
183 } else if ok {
184 // empty string is allowed
185 } else {
186 return reportMCPError("'motivation' must be a string")
187 }
188 }
189
190 if statusVal, exists := arguments["status"]; exists && statusVal != nil {
191 if status, ok := statusVal.(string); ok && status != "" {
192 validStatus := map[string]bool{"later": true, "next": true, "started": true, "waiting": true, "completed": true}
193 if !validStatus[status] {
194 return reportMCPError("'status' must be one of 'later', 'next', 'started', 'waiting', or 'completed'")
195 }
196 } else if ok {
197 // empty string is allowed
198 } else {
199 return reportMCPError("'status' must be a string")
200 }
201 }
202
203 if scheduledOnArg, exists := arguments["scheduled_on"]; exists {
204 if scheduledOnStr, ok := scheduledOnArg.(string); ok && scheduledOnStr != "" {
205 if _, err := time.Parse(time.RFC3339, scheduledOnStr); err != nil {
206 return reportMCPError(fmt.Sprintf("Invalid format for scheduled_on: '%s'. Must be RFC3339 timestamp (e.g., YYYY-MM-DDTHH:MM:SSZ). Use get_task_timestamp tool first.", scheduledOnStr))
207 }
208 } else if !ok {
209 return reportMCPError("Invalid type for scheduled_on argument: expected string.")
210 }
211 }
212
213 client := lunatask.NewClient(h.config.AccessToken)
214 var task lunatask.CreateTaskRequest
215 argBytes, err := json.Marshal(arguments)
216 if err != nil {
217 return reportMCPError(fmt.Sprintf("Failed to process arguments: %v", err))
218 }
219 if err := json.Unmarshal(argBytes, &task); err != nil {
220 return reportMCPError(fmt.Sprintf("Failed to parse arguments: %v", err))
221 }
222
223 response, err := client.CreateTask(ctx, &task)
224 if err != nil {
225 return reportMCPError(fmt.Sprintf("%v", err))
226 }
227
228 if response == nil {
229 return &mcp.CallToolResult{
230 Content: []mcp.Content{
231 mcp.TextContent{
232 Type: "text",
233 Text: "Task already exists (not an error).",
234 },
235 },
236 }, nil
237 }
238
239 return &mcp.CallToolResult{
240 Content: []mcp.Content{
241 mcp.TextContent{
242 Type: "text",
243 Text: fmt.Sprintf("Task created successfully with ID: %s", response.Task.ID),
244 },
245 },
246 }, nil
247}
248
249// HandleUpdateTask handles the update_task tool call.
250func (h *Handlers) HandleUpdateTask(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
251 arguments := request.Params.Arguments
252
253 taskID, ok := arguments["task_id"].(string)
254 if !ok || taskID == "" {
255 return reportMCPError("Missing or invalid required argument: task_id")
256 }
257
258 if _, err := LoadLocation(h.config.Timezone); err != nil {
259 return reportMCPError(err.Error())
260 }
261
262 updatePayload := lunatask.CreateTaskRequest{}
263
264 var specifiedAreaProvider AreaProvider
265 areaIDProvided := false
266
267 if areaIDArg, exists := arguments["area_id"]; exists {
268 if areaIDStr, ok := areaIDArg.(string); ok && areaIDStr != "" {
269 updatePayload.AreaID = areaIDStr
270 areaIDProvided = true
271 found := false
272 for _, ap := range h.config.Areas {
273 if ap.GetID() == areaIDStr {
274 specifiedAreaProvider = ap
275 found = true
276 break
277 }
278 }
279 if !found {
280 return reportMCPError(fmt.Sprintf("Area not found for given area_id: %s", areaIDStr))
281 }
282 } else if !ok && areaIDArg != nil {
283 return reportMCPError("Invalid type for area_id argument: expected string.")
284 }
285 }
286
287 if goalIDArg, exists := arguments["goal_id"]; exists {
288 if goalIDStr, ok := goalIDArg.(string); ok && goalIDStr != "" {
289 updatePayload.GoalID = goalIDStr
290 if specifiedAreaProvider != nil {
291 foundGoal := false
292 for _, goal := range specifiedAreaProvider.GetGoals() {
293 if goal.GetID() == goalIDStr {
294 foundGoal = true
295 break
296 }
297 }
298 if !foundGoal {
299 return reportMCPError(fmt.Sprintf("Goal not found in specified area '%s' for given goal_id: %s", specifiedAreaProvider.GetName(), goalIDStr))
300 }
301 } else if areaIDProvided {
302 return reportMCPError("Internal error: area_id provided but area details not loaded for goal validation.")
303 }
304 } else if !ok && goalIDArg != nil {
305 return reportMCPError("Invalid type for goal_id argument: expected string.")
306 }
307 }
308
309 nameArg := arguments["name"]
310 if nameStr, ok := nameArg.(string); ok {
311 updatePayload.Name = nameStr
312 } else {
313 return reportMCPError("Invalid type for name argument: expected string.")
314 }
315
316 if noteArg, exists := arguments["note"]; exists {
317 if noteStr, ok := noteArg.(string); ok {
318 updatePayload.Note = noteStr
319 } else if !ok && noteArg != nil {
320 return reportMCPError("Invalid type for note argument: expected string.")
321 }
322 }
323
324 if estimateArg, exists := arguments["estimate"]; exists && estimateArg != nil {
325 if estimateVal, ok := estimateArg.(float64); ok {
326 updatePayload.Estimate = int(estimateVal)
327 } else {
328 return reportMCPError("Invalid type for estimate argument: expected number.")
329 }
330 }
331
332 if priorityArg, exists := arguments["priority"]; exists && priorityArg != nil {
333 priorityStr, ok := priorityArg.(string)
334 if !ok {
335 return reportMCPError("Invalid type for 'priority' argument: expected string.")
336 }
337 priorityMap := map[string]int{
338 "lowest": -2,
339 "low": -1,
340 "neutral": 0,
341 "high": 1,
342 "highest": 2,
343 }
344 translatedPriority, isValid := priorityMap[strings.ToLower(priorityStr)]
345 if !isValid {
346 return reportMCPError(fmt.Sprintf("Invalid 'priority' value: '%s'. Must be one of 'lowest', 'low', 'neutral', 'high', 'highest'.", priorityStr))
347 }
348 updatePayload.Priority = translatedPriority
349 }
350
351 if motivationArg, exists := arguments["motivation"]; exists {
352 if motivationStr, ok := motivationArg.(string); ok {
353 if motivationStr != "" {
354 validMotivations := map[string]bool{"must": true, "should": true, "want": true}
355 if !validMotivations[motivationStr] {
356 return reportMCPError("'motivation' must be one of 'must', 'should', or 'want', or empty to clear.")
357 }
358 }
359 updatePayload.Motivation = motivationStr
360 } else if !ok && motivationArg != nil {
361 return reportMCPError("Invalid type for motivation argument: expected string.")
362 }
363 }
364
365 if statusArg, exists := arguments["status"]; exists {
366 if statusStr, ok := statusArg.(string); ok {
367 if statusStr != "" {
368 validStatus := map[string]bool{"later": true, "next": true, "started": true, "waiting": true, "completed": true}
369 if !validStatus[statusStr] {
370 return reportMCPError("'status' must be one of 'later', 'next', 'started', 'waiting', 'completed', or empty.")
371 }
372 }
373 updatePayload.Status = statusStr
374 } else if !ok && statusArg != nil {
375 return reportMCPError("Invalid type for status argument: expected string.")
376 }
377 }
378
379 if scheduledOnArg, exists := arguments["scheduled_on"]; exists {
380 if scheduledOnStr, ok := scheduledOnArg.(string); ok {
381 if scheduledOnStr != "" {
382 if _, err := time.Parse(time.RFC3339, scheduledOnStr); err != nil {
383 return reportMCPError(fmt.Sprintf("Invalid format for scheduled_on: '%s'. Must be RFC3339. Use get_task_timestamp tool.", scheduledOnStr))
384 }
385 }
386 updatePayload.ScheduledOn = scheduledOnStr
387 } else if !ok && scheduledOnArg != nil {
388 return reportMCPError("Invalid type for scheduled_on argument: expected string.")
389 }
390 }
391
392 client := lunatask.NewClient(h.config.AccessToken)
393 response, err := client.UpdateTask(ctx, taskID, &updatePayload)
394 if err != nil {
395 return reportMCPError(fmt.Sprintf("Failed to update task: %v", err))
396 }
397
398 return &mcp.CallToolResult{
399 Content: []mcp.Content{
400 mcp.TextContent{
401 Type: "text",
402 Text: fmt.Sprintf("Task updated successfully. ID: %s", response.Task.ID),
403 },
404 },
405 }, nil
406}
407
408// HandleDeleteTask handles the delete_task tool call.
409func (h *Handlers) HandleDeleteTask(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
410 taskID, ok := request.Params.Arguments["task_id"].(string)
411 if !ok || taskID == "" {
412 return reportMCPError("Missing or invalid required argument: task_id")
413 }
414
415 client := lunatask.NewClient(h.config.AccessToken)
416 _, err := client.DeleteTask(ctx, taskID)
417 if err != nil {
418 return reportMCPError(fmt.Sprintf("Failed to delete task: %v", err))
419 }
420
421 return &mcp.CallToolResult{
422 Content: []mcp.Content{
423 mcp.TextContent{
424 Type: "text",
425 Text: "Task deleted successfully.",
426 },
427 },
428 }, nil
429}
430
431// HandleListHabitsAndActivities handles the list_habits_and_activities tool call.
432func (h *Handlers) HandleListHabitsAndActivities(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
433 var b strings.Builder
434 for _, habit := range h.config.Habits {
435 fmt.Fprintf(&b, "- %s: %s\n", habit.GetName(), habit.GetID())
436 }
437 return &mcp.CallToolResult{
438 Content: []mcp.Content{
439 mcp.TextContent{
440 Type: "text",
441 Text: b.String(),
442 },
443 },
444 }, nil
445}
446
447// HandleTrackHabitActivity handles the track_habit_activity tool call.
448func (h *Handlers) HandleTrackHabitActivity(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
449 habitID, ok := request.Params.Arguments["habit_id"].(string)
450 if !ok || habitID == "" {
451 return reportMCPError("Missing or invalid required argument: habit_id")
452 }
453
454 performedOn, ok := request.Params.Arguments["performed_on"].(string)
455 if !ok || performedOn == "" {
456 return reportMCPError("Missing or invalid required argument: performed_on")
457 }
458
459 client := lunatask.NewClient(h.config.AccessToken)
460 habitRequest := &lunatask.TrackHabitActivityRequest{
461 PerformedOn: performedOn,
462 }
463
464 resp, err := client.TrackHabitActivity(ctx, habitID, habitRequest)
465 if err != nil {
466 return reportMCPError(fmt.Sprintf("Failed to track habit activity: %v", err))
467 }
468
469 return &mcp.CallToolResult{
470 Content: []mcp.Content{
471 mcp.TextContent{
472 Type: "text",
473 Text: fmt.Sprintf("Habit activity tracked successfully. Status: %s, Message: %s", resp.Status, resp.Message),
474 },
475 },
476 }, nil
477}