1// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
2//
3// SPDX-License-Identifier: AGPL-3.0-or-later
4
5package main
6
7import (
8 "context"
9 "encoding/json"
10 "fmt"
11 "log"
12 "os"
13 "strings"
14 "time"
15
16 "github.com/ijt/go-anytime"
17
18 "github.com/BurntSushi/toml"
19 "github.com/mark3labs/mcp-go/mcp"
20 "github.com/mark3labs/mcp-go/server"
21
22 "git.sr.ht/~amolith/lunatask-mcp-server/lunatask"
23)
24
25// Goal represents a Lunatask goal with its name and ID
26type Goal struct {
27 Name string `toml:"name"`
28 ID string `toml:"id"`
29}
30
31// Area represents a Lunatask area with its name, ID, and its goals
32type Area struct {
33 Name string `toml:"name"`
34 ID string `toml:"id"`
35 Goals []Goal `toml:"goals"`
36}
37
38type Habit struct {
39 Name string `toml:"name"`
40 ID string `toml:"id"`
41}
42
43// Config holds the application's configuration loaded from TOML
44type ServerConfig struct {
45 Host string `toml:"host"`
46 Port int `toml:"port"`
47}
48
49type Config struct {
50 AccessToken string `toml:"access_token"`
51 Areas []Area `toml:"areas"`
52 Server ServerConfig `toml:"server"`
53 Timezone string `toml:"timezone"`
54 Habit []Habit `toml:"habit"`
55}
56
57var version = ""
58
59func main() {
60 configPath := "./config.toml"
61 for i, arg := range os.Args {
62 switch arg {
63 case "-v", "--version":
64 if version == "" {
65 version = "unknown, build with `just build` or copy/paste the build command from ./justfile"
66 }
67 fmt.Println("lunatask-mcp-server:", version)
68 os.Exit(0)
69 case "-c", "--config":
70 if i+1 < len(os.Args) {
71 configPath = os.Args[i+1]
72 }
73 }
74 }
75
76 if _, err := os.Stat(configPath); os.IsNotExist(err) {
77 createDefaultConfigFile(configPath)
78 }
79
80 var config Config
81 if _, err := toml.DecodeFile(configPath, &config); err != nil {
82 log.Fatalf("Failed to load config file %s: %v", configPath, err)
83 }
84
85 if config.AccessToken == "" || len(config.Areas) == 0 {
86 log.Fatalf("Config file must provide access_token and at least one area.")
87 }
88
89 for i, area := range config.Areas {
90 if area.Name == "" || area.ID == "" {
91 log.Fatalf("All areas (areas[%d]) must have both a name and id", i)
92 }
93 for j, goal := range area.Goals {
94 if goal.Name == "" || goal.ID == "" {
95 log.Fatalf("All goals (areas[%d].goals[%d]) must have both a name and id", i, j)
96 }
97 }
98 }
99
100 // Validate timezone config on startup
101 if _, err := loadLocation(config.Timezone); err != nil {
102 log.Fatalf("Timezone validation failed: %v", err)
103 }
104
105 mcpServer := NewMCPServer(&config)
106
107 baseURL := fmt.Sprintf("http://%s:%d", config.Server.Host, config.Server.Port)
108 sseServer := server.NewSSEServer(mcpServer, server.WithBaseURL(baseURL))
109 listenAddr := fmt.Sprintf("%s:%d", config.Server.Host, config.Server.Port)
110 log.Printf("SSE server listening on %s (baseURL: %s)", listenAddr, baseURL)
111 if err := sseServer.Start(listenAddr); err != nil {
112 log.Fatalf("Server error: %v", err)
113 }
114}
115
116// loadLocation loads a timezone location string, returning a *time.Location or error
117func loadLocation(timezone string) (*time.Location, error) {
118 if timezone == "" {
119 return nil, fmt.Errorf("timezone is not configured; please set the 'timezone' value in your config file (e.g. 'UTC' or 'America/New_York')")
120 }
121 loc, err := time.LoadLocation(timezone)
122 if err != nil {
123 return nil, fmt.Errorf("could not load timezone '%s': %v", timezone, err)
124 }
125 return loc, nil
126}
127
128// closeFile properly closes a file, handling any errors
129func closeFile(f *os.File) {
130 err := f.Close()
131 if err != nil {
132 log.Printf("Error closing file: %v", err)
133 }
134}
135
136func NewMCPServer(config *Config) *server.MCPServer {
137 hooks := &server.Hooks{}
138
139 hooks.AddBeforeAny(func(ctx context.Context, id any, method mcp.MCPMethod, message any) {
140 fmt.Printf("beforeAny: %s, %v, %v\n", method, id, message)
141 })
142 hooks.AddOnSuccess(func(ctx context.Context, id any, method mcp.MCPMethod, message any, result any) {
143 fmt.Printf("onSuccess: %s, %v, %v, %v\n", method, id, message, result)
144 })
145 hooks.AddOnError(func(ctx context.Context, id any, method mcp.MCPMethod, message any, err error) {
146 fmt.Printf("onError: %s, %v, %v, %v\n", method, id, message, err)
147 })
148 hooks.AddBeforeInitialize(func(ctx context.Context, id any, message *mcp.InitializeRequest) {
149 fmt.Printf("beforeInitialize: %v, %v\n", id, message)
150 })
151 hooks.AddAfterInitialize(func(ctx context.Context, id any, message *mcp.InitializeRequest, result *mcp.InitializeResult) {
152 fmt.Printf("afterInitialize: %v, %v, %v\n", id, message, result)
153 })
154 hooks.AddAfterCallTool(func(ctx context.Context, id any, message *mcp.CallToolRequest, result *mcp.CallToolResult) {
155 fmt.Printf("afterCallTool: %v, %v, %v\n", id, message, result)
156 })
157 hooks.AddBeforeCallTool(func(ctx context.Context, id any, message *mcp.CallToolRequest) {
158 fmt.Printf("beforeCallTool: %v, %v\n", id, message)
159 })
160
161 mcpServer := server.NewMCPServer(
162 "Lunatask MCP Server",
163 "0.1.0",
164 server.WithHooks(hooks),
165 server.WithToolCapabilities(true),
166 )
167
168 mcpServer.AddTool(mcp.NewTool("get_timestamp",
169 mcp.WithDescription("Retrieves the formatted timestamp for a task"),
170 mcp.WithString("natural_language_date",
171 mcp.Description("Natural language date as described by the user, e.g. '1 week', 'tomorrow', 'sunday at 19:00', 'now', etc."),
172 mcp.Required(),
173 ),
174 ), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
175 natLangDate, ok := request.Params.Arguments["natural_language_date"].(string)
176 if !ok || natLangDate == "" {
177 return reportMCPError("Missing or invalid required argument: natural_language_date")
178 }
179 loc, err := loadLocation(config.Timezone)
180 if err != nil {
181 return reportMCPError(err.Error())
182 }
183 parsedTime, err := anytime.Parse(natLangDate, time.Now().In(loc))
184 if err != nil {
185 return reportMCPError(fmt.Sprintf("Could not parse natural language date: %v", err))
186 }
187 return &mcp.CallToolResult{
188 Content: []mcp.Content{
189 mcp.TextContent{
190 Type: "text",
191 Text: parsedTime.Format(time.RFC3339),
192 },
193 },
194 }, nil
195 })
196
197 mcpServer.AddTool(
198 mcp.NewTool(
199 "list_areas_and_goals",
200 mcp.WithDescription("List areas and goals and their IDs."),
201 ),
202 func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
203 var b strings.Builder
204 for _, area := range config.Areas {
205 fmt.Fprintf(&b, "- %s: %s\n", area.Name, area.ID)
206 for _, goal := range area.Goals {
207 fmt.Fprintf(&b, " - %s: %s\n", goal.Name, goal.ID)
208 }
209 }
210 return &mcp.CallToolResult{
211 Content: []mcp.Content{
212 mcp.TextContent{
213 Type: "text",
214 Text: b.String(),
215 },
216 },
217 }, nil
218 },
219 )
220
221 mcpServer.AddTool(mcp.NewTool("create_task",
222 mcp.WithDescription("Creates a new task"),
223 mcp.WithString("area_id",
224 mcp.Description("Area ID in which to create the task"),
225 mcp.Required(),
226 ),
227 mcp.WithString("goal_id",
228 mcp.Description("Goal ID, which must belong to the provided area, to associate the task with."),
229 ),
230 mcp.WithString("name",
231 mcp.Description("Plain text task name using sentence case."),
232 mcp.Required(),
233 ),
234 mcp.WithString("note",
235 mcp.Description("Note attached to the task, optionally Markdown-formatted"),
236 ),
237 mcp.WithNumber("estimate",
238 mcp.Description("Estimated time completion time in minutes"),
239 mcp.Min(0),
240 mcp.Max(1440),
241 ),
242 mcp.WithString("priority",
243 mcp.Description("Task priority, omit unless priority is mentioned"),
244 mcp.Enum("lowest", "low", "neutral", "high", "highest"),
245 ),
246 mcp.WithString("motivation",
247 mcp.Description("Motivation driving task creation"),
248 mcp.Enum("must", "should", "want"),
249 ),
250 mcp.WithString("status",
251 mcp.Description("Task state, such as in progress, provided as 'started', already started, provided as 'started', soon, provided as 'next', blocked as waiting, omit unspecified, and so on. Intuit the task's status."),
252 mcp.Enum("later", "next", "started", "waiting", "completed"),
253 ),
254 mcp.WithString("scheduled_on",
255 mcp.Description("Formatted timestamp from get_task_timestamp tool"),
256 ),
257 ), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
258 return handleCreateTask(ctx, request, config)
259 })
260
261 mcpServer.AddTool(mcp.NewTool("update_task",
262 mcp.WithDescription("Updates an existing task. Only provided fields will be targeted for update."),
263 mcp.WithString("task_id",
264 mcp.Description("ID of the task to update."),
265 mcp.Required(),
266 ),
267 mcp.WithString("area_id",
268 mcp.Description("New Area ID for the task. Must be a valid Area ID from 'list_areas_and_goals'."),
269 ),
270 mcp.WithString("goal_id",
271 mcp.Description("New Goal ID for the task. Must be a valid Goal ID from 'list_areas_and_goals'."),
272 ),
273 mcp.WithString("name",
274 mcp.Description("New plain text task name using sentence case. Sending an empty string WILL clear the name."),
275 mcp.Required(),
276 ),
277 mcp.WithString("note",
278 mcp.Description("New note attached to the task, optionally Markdown-formatted. Sending an empty string WILL clear the note."),
279 ),
280 mcp.WithNumber("estimate",
281 mcp.Description("New estimated time completion time in minutes."),
282 mcp.Min(0),
283 mcp.Max(720), // Aligned with CreateTaskRequest validation tag
284 ),
285 mcp.WithNumber("priority",
286 mcp.Description("New task priority, -2 being lowest, 0 being normal, and 2 being highest."),
287 mcp.Min(-2),
288 mcp.Max(2),
289 ),
290 mcp.WithString("motivation",
291 mcp.Description("New motivation driving the task."),
292 mcp.Enum("must", "should", "want", ""), // Allow empty string to potentially clear/unset
293 ),
294 mcp.WithString("status",
295 mcp.Description("New task state."),
296 mcp.Enum("later", "next", "started", "waiting", "completed", ""), // Allow empty string
297 ),
298 mcp.WithString("scheduled_on",
299 mcp.Description("New scheduled date/time as a formatted timestamp from get_task_timestamp tool. Sending an empty string might clear the scheduled date."),
300 ),
301 ), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
302 return handleUpdateTask(ctx, request, config)
303 })
304
305 mcpServer.AddTool(mcp.NewTool("delete_task",
306 mcp.WithDescription("Deletes an existing task"),
307 mcp.WithString("task_id",
308 mcp.Description("ID of the task to delete."),
309 mcp.Required(),
310 ),
311 ), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
312 return handleDeleteTask(ctx, request, config)
313 })
314
315 mcpServer.AddTool(
316 mcp.NewTool(
317 "list_habits_and_activities",
318 mcp.WithDescription("List habits and their IDs for tracking or marking complete with tracking_habit_activity"),
319 ),
320 func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
321 var b strings.Builder
322 for _, habit := range config.Habit {
323 fmt.Fprintf(&b, "- %s: %s\n", habit.Name, habit.ID)
324 }
325 return &mcp.CallToolResult{
326 Content: []mcp.Content{
327 mcp.TextContent{
328 Type: "text",
329 Text: b.String(),
330 },
331 },
332 }, nil
333 },
334 )
335
336 mcpServer.AddTool(mcp.NewTool("track_habit_activity",
337 mcp.WithDescription("Tracks an activity or a habit in Lunatask"),
338 mcp.WithString("habit_id",
339 mcp.Description("ID of the habit to track activity for."),
340 mcp.Required(),
341 ),
342 mcp.WithString("performed_on",
343 mcp.Description("The timestamp the habit was performed, first obtained with get_timestamp."),
344 mcp.Required(),
345 ),
346 ), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
347 return handleTrackHabitActivity(ctx, request, config)
348 })
349
350 return mcpServer
351}
352
353func reportMCPError(msg string) (*mcp.CallToolResult, error) {
354 return &mcp.CallToolResult{
355 IsError: true,
356 Content: []mcp.Content{mcp.TextContent{Type: "text", Text: msg}},
357 }, nil
358}
359
360// handleCreateTask handles the creation of a task in Lunatask
361func handleCreateTask(
362 ctx context.Context,
363 request mcp.CallToolRequest,
364 config *Config,
365) (*mcp.CallToolResult, error) {
366 arguments := request.Params.Arguments
367
368 // Validate timezone before proceeding any further
369 if _, err := loadLocation(config.Timezone); err != nil {
370 return reportMCPError(err.Error())
371 }
372
373 areaID, ok := arguments["area_id"].(string)
374 if !ok || areaID == "" {
375 return reportMCPError("Missing or invalid required argument: area_id")
376 }
377
378 var area *Area
379 for i := range config.Areas {
380 if config.Areas[i].ID == areaID {
381 area = &config.Areas[i]
382 break
383 }
384 }
385 if area == nil {
386 return reportMCPError("Area not found for given area_id")
387 }
388
389 if goalID, exists := arguments["goal_id"].(string); exists && goalID != "" {
390 found := false
391 for _, goal := range area.Goals {
392 if goal.ID == goalID {
393 found = true
394 break
395 }
396 }
397 if !found {
398 return reportMCPError("Goal not found in specified area for given goal_id")
399 }
400 }
401
402 // Priority translation and validation
403 priorityMap := map[string]int{
404 "lowest": -2,
405 "low": -1,
406 "neutral": 0,
407 "high": 1,
408 "highest": 2,
409 }
410
411 if priorityArg, exists := arguments["priority"]; exists && priorityArg != nil {
412 priorityStr, ok := priorityArg.(string)
413 if !ok {
414 // This should ideally be caught by MCP schema validation if type is string.
415 return reportMCPError("Invalid type for 'priority' argument: expected string.")
416 }
417 // An empty string for priority is not valid as it's not in the enum.
418 // The map lookup will fail for an empty string, triggering the !isValid block.
419
420 translatedPriority, isValid := priorityMap[strings.ToLower(priorityStr)]
421 if !isValid {
422 return reportMCPError(fmt.Sprintf("Invalid 'priority' value: '%s'. Must be one of 'lowest', 'low', 'neutral', 'high', 'highest'.", priorityStr))
423 }
424 arguments["priority"] = translatedPriority // Update the map with the integer value
425 }
426
427 if motivationVal, exists := arguments["motivation"]; exists && motivationVal != nil {
428 if motivation, ok := motivationVal.(string); ok && motivation != "" {
429 validMotivations := map[string]bool{"must": true, "should": true, "want": true}
430 if !validMotivations[motivation] {
431 return reportMCPError("'motivation' must be one of 'must', 'should', or 'want'")
432 }
433 } else if ok {
434 // empty string is allowed
435 } else {
436 return reportMCPError("'motivation' must be a string")
437 }
438 }
439
440 if statusVal, exists := arguments["status"]; exists && statusVal != nil {
441 if status, ok := statusVal.(string); ok && status != "" {
442 validStatus := map[string]bool{"later": true, "next": true, "started": true, "waiting": true, "completed": true}
443 if !validStatus[status] {
444 return reportMCPError("'status' must be one of 'later', 'next', 'started', 'waiting', or 'completed'")
445 }
446 } else if ok {
447 // empty string is allowed
448 } else {
449 return reportMCPError("'status' must be a string")
450 }
451 }
452
453 // Validate scheduled_on format if provided
454 if scheduledOnArg, exists := arguments["scheduled_on"]; exists {
455 if scheduledOnStr, ok := scheduledOnArg.(string); ok && scheduledOnStr != "" {
456 if _, err := time.Parse(time.RFC3339, scheduledOnStr); err != nil {
457 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))
458 }
459 } else if !ok {
460 // It exists but isn't a string, which shouldn't happen based on MCP schema but check anyway
461 return reportMCPError("Invalid type for scheduled_on argument: expected string.")
462 }
463 // If it's an empty string, it's handled by the API or omitempty later, no need to validate format.
464 }
465
466 // Create Lunatask client
467 client := lunatask.NewClient(config.AccessToken)
468
469 // Prepare the task request
470 var task lunatask.CreateTaskRequest
471 argBytes, err := json.Marshal(arguments)
472 if err != nil {
473 return reportMCPError(fmt.Sprintf("Failed to process arguments: %v", err))
474 }
475 if err := json.Unmarshal(argBytes, &task); err != nil {
476 return reportMCPError(fmt.Sprintf("Failed to parse arguments: %v", err))
477 }
478
479 // Call the client to create the task
480 response, err := client.CreateTask(ctx, &task)
481 if err != nil {
482 return reportMCPError(fmt.Sprintf("%v", err))
483 }
484
485 // Handle the case where task already exists
486 if response == nil {
487 return &mcp.CallToolResult{
488 Content: []mcp.Content{
489 mcp.TextContent{
490 Type: "text",
491 Text: "Task already exists (not an error).",
492 },
493 },
494 }, nil
495 }
496
497 return &mcp.CallToolResult{
498 Content: []mcp.Content{
499 mcp.TextContent{
500 Type: "text",
501 Text: fmt.Sprintf("Task created successfully with ID: %s", response.Task.ID),
502 },
503 },
504 }, nil
505}
506
507// handleUpdateTask handles the update of a task in Lunatask
508func handleUpdateTask(
509 ctx context.Context,
510 request mcp.CallToolRequest,
511 config *Config,
512) (*mcp.CallToolResult, error) {
513 arguments := request.Params.Arguments
514
515 taskID, ok := arguments["task_id"].(string)
516 if !ok || taskID == "" {
517 return reportMCPError("Missing or invalid required argument: task_id")
518 }
519
520 // Validate timezone before proceeding, as it might be used by API or for scheduled_on
521 if _, err := loadLocation(config.Timezone); err != nil {
522 return reportMCPError(err.Error())
523 }
524
525 updatePayload := lunatask.CreateTaskRequest{} // Reusing CreateTaskRequest for the update body
526
527 var specifiedArea *Area // Used for goal validation if area_id is also specified
528 areaIDProvided := false
529
530 if areaIDArg, exists := arguments["area_id"]; exists {
531 if areaIDStr, ok := areaIDArg.(string); ok && areaIDStr != "" {
532 updatePayload.AreaID = areaIDStr
533 areaIDProvided = true
534 found := false
535 for i := range config.Areas {
536 if config.Areas[i].ID == areaIDStr {
537 specifiedArea = &config.Areas[i]
538 found = true
539 break
540 }
541 }
542 if !found {
543 return reportMCPError(fmt.Sprintf("Area not found for given area_id: %s", areaIDStr))
544 }
545 } else if !ok && areaIDArg != nil { // Exists but not a string
546 return reportMCPError("Invalid type for area_id argument: expected string.")
547 }
548 // If areaIDArg is an empty string or nil, it's fine, AreaID in payload will be "" (or not set if using pointers/map)
549 // With CreateTaskRequest, it will be "" if not explicitly set to a non-empty string.
550 }
551
552 if goalIDArg, exists := arguments["goal_id"]; exists {
553 if goalIDStr, ok := goalIDArg.(string); ok && goalIDStr != "" {
554 updatePayload.GoalID = goalIDStr
555 // If goal_id is specified, but area_id is not, we cannot validate the goal against a specific area from config.
556 // The API will have to handle this. For stricter local validation, one might require area_id here.
557 // For now, we proceed, assuming the API can handle it or the goal is in the task's current (unchanged) area.
558 // If area_id WAS provided, specifiedArea would be set.
559 // If area_id was NOT provided, we need to check all areas, or rely on API.
560 // Let's enforce that if goal_id is given, and area_id is also given, the goal must be in that area.
561 // If goal_id is given and area_id is NOT, we can't validate locally.
562 // The description for goal_id parameter hints at this.
563 if specifiedArea != nil { // Only validate goal if its intended area (new or existing) is known
564 foundGoal := false
565 for _, goal := range specifiedArea.Goals {
566 if goal.ID == goalIDStr {
567 foundGoal = true
568 break
569 }
570 }
571 if !foundGoal {
572 return reportMCPError(fmt.Sprintf("Goal not found in specified area '%s' for given goal_id: %s", specifiedArea.Name, goalIDStr))
573 }
574 } else if areaIDProvided { // area_id was provided but somehow specifiedArea is nil (should be caught above)
575 return reportMCPError("Internal error: area_id provided but area details not loaded for goal validation.")
576 }
577 } else if !ok && goalIDArg != nil {
578 return reportMCPError("Invalid type for goal_id argument: expected string.")
579 }
580 }
581
582 // Name is now required by the MCP tool definition.
583 // The lunatask.ValidateTask (called by client.UpdateTask) will ensure it's not an empty string
584 // due to the "required" tag on CreateTaskRequest.Name.
585 nameArg := arguments["name"] // MCP framework ensures "name" exists.
586 if nameStr, ok := nameArg.(string); ok {
587 updatePayload.Name = nameStr
588 } else {
589 // This case should ideally be caught by MCP's type checking.
590 // A defensive check is good.
591 return reportMCPError("Invalid type for name argument: expected string.")
592 }
593
594 if noteArg, exists := arguments["note"]; exists {
595 if noteStr, ok := noteArg.(string); ok {
596 updatePayload.Note = noteStr
597 } else if !ok && noteArg != nil {
598 return reportMCPError("Invalid type for note argument: expected string.")
599 }
600 }
601
602 if estimateArg, exists := arguments["estimate"]; exists && estimateArg != nil {
603 if estimateVal, ok := estimateArg.(float64); ok {
604 // Validation for min/max (0-720) is in CreateTaskRequest struct tags,
605 // checked by lunatask.ValidateTask.
606 // MCP tool also defines this range.
607 updatePayload.Estimate = int(estimateVal)
608 } else {
609 return reportMCPError("Invalid type for estimate argument: expected number.")
610 }
611 }
612
613 if priorityArg, exists := arguments["priority"]; exists && priorityArg != nil {
614 if priorityVal, ok := priorityArg.(float64); ok {
615 if priorityVal < -2 || priorityVal > 2 { // MCP tool range
616 return reportMCPError("'priority' must be between -2 and 2 (inclusive).")
617 }
618 updatePayload.Priority = int(priorityVal)
619 } else {
620 return reportMCPError("Invalid type for priority argument: expected number.")
621 }
622 }
623
624 if motivationArg, exists := arguments["motivation"]; exists {
625 if motivationStr, ok := motivationArg.(string); ok {
626 if motivationStr != "" { // Allow empty string to be passed if desired (e.g. to clear)
627 validMotivations := map[string]bool{"must": true, "should": true, "want": true}
628 if !validMotivations[motivationStr] {
629 return reportMCPError("'motivation' must be one of 'must', 'should', or 'want', or empty to clear.")
630 }
631 }
632 updatePayload.Motivation = motivationStr
633 } else if !ok && motivationArg != nil {
634 return reportMCPError("Invalid type for motivation argument: expected string.")
635 }
636 }
637
638 if statusArg, exists := arguments["status"]; exists {
639 if statusStr, ok := statusArg.(string); ok {
640 if statusStr != "" { // Allow empty string
641 validStatus := map[string]bool{"later": true, "next": true, "started": true, "waiting": true, "completed": true}
642 if !validStatus[statusStr] {
643 return reportMCPError("'status' must be one of 'later', 'next', 'started', 'waiting', 'completed', or empty.")
644 }
645 }
646 updatePayload.Status = statusStr
647 } else if !ok && statusArg != nil {
648 return reportMCPError("Invalid type for status argument: expected string.")
649 }
650 }
651
652 if scheduledOnArg, exists := arguments["scheduled_on"]; exists {
653 if scheduledOnStr, ok := scheduledOnArg.(string); ok {
654 if scheduledOnStr != "" { // Allow empty string to potentially clear scheduled_on
655 if _, err := time.Parse(time.RFC3339, scheduledOnStr); err != nil {
656 return reportMCPError(fmt.Sprintf("Invalid format for scheduled_on: '%s'. Must be RFC3339. Use get_task_timestamp tool.", scheduledOnStr))
657 }
658 }
659 updatePayload.ScheduledOn = scheduledOnStr
660 } else if !ok && scheduledOnArg != nil {
661 return reportMCPError("Invalid type for scheduled_on argument: expected string.")
662 }
663 }
664
665 // Create Lunatask client
666 client := lunatask.NewClient(config.AccessToken)
667
668 // Call the client to update the task
669 // The updatePayload (CreateTaskRequest) will be validated by client.UpdateTask->ValidateTask
670 response, err := client.UpdateTask(ctx, taskID, &updatePayload)
671 if err != nil {
672 return reportMCPError(fmt.Sprintf("Failed to update task: %v", err))
673 }
674
675 // The API returns the updated task details.
676 // We can construct a more detailed message if needed, e.g., by marshaling response.Task to JSON.
677 return &mcp.CallToolResult{
678 Content: []mcp.Content{
679 mcp.TextContent{
680 Type: "text",
681 Text: fmt.Sprintf("Task updated successfully. ID: %s", response.Task.ID),
682 },
683 },
684 }, nil
685}
686
687func handleDeleteTask(
688 ctx context.Context,
689 request mcp.CallToolRequest,
690 config *Config,
691) (*mcp.CallToolResult, error) {
692 taskID, ok := request.Params.Arguments["task_id"].(string)
693 if !ok || taskID == "" {
694 return reportMCPError("Missing or invalid required argument: task_id")
695 }
696
697 // Create the Lunatask client
698 client := lunatask.NewClient(config.AccessToken)
699
700 // Delete the task
701 _, err := client.DeleteTask(ctx, taskID)
702 if err != nil {
703 return reportMCPError(fmt.Sprintf("Failed to delete task: %v", err))
704 }
705
706 // Return success response
707 return &mcp.CallToolResult{
708 Content: []mcp.Content{
709 mcp.TextContent{
710 Type: "text",
711 Text: "Task deleted successfully.",
712 },
713 },
714 }, nil
715}
716
717// handleTrackHabitActivity handles tracking a habit activity in Lunatask
718func handleTrackHabitActivity(
719 ctx context.Context,
720 request mcp.CallToolRequest,
721 config *Config,
722) (*mcp.CallToolResult, error) {
723 habitID, ok := request.Params.Arguments["habit_id"].(string)
724 if !ok || habitID == "" {
725 return reportMCPError("Missing or invalid required argument: habit_id")
726 }
727
728 performedOn, ok := request.Params.Arguments["performed_on"].(string)
729 if !ok || performedOn == "" {
730 return reportMCPError("Missing or invalid required argument: performed_on")
731 }
732
733 // Create the Lunatask client
734 client := lunatask.NewClient(config.AccessToken)
735
736 // Create the request
737 habitRequest := &lunatask.TrackHabitActivityRequest{
738 PerformedOn: performedOn,
739 }
740
741 // Track the habit activity
742 resp, err := client.TrackHabitActivity(ctx, habitID, habitRequest)
743 if err != nil {
744 return reportMCPError(fmt.Sprintf("Failed to track habit activity: %v", err))
745 }
746
747 // Return success response
748 return &mcp.CallToolResult{
749 Content: []mcp.Content{
750 mcp.TextContent{
751 Type: "text",
752 Text: fmt.Sprintf("Habit activity tracked successfully. Status: %s, Message: %s", resp.Status, resp.Message),
753 },
754 },
755 }, nil
756}
757
758func createDefaultConfigFile(configPath string) {
759 defaultConfig := Config{
760 Server: ServerConfig{
761 Host: "localhost",
762 Port: 8080,
763 },
764 AccessToken: "",
765 Timezone: "UTC",
766 Areas: []Area{{
767 Name: "Example Area",
768 ID: "area-id-placeholder",
769 Goals: []Goal{{
770 Name: "Example Goal",
771 ID: "goal-id-placeholder",
772 }},
773 }},
774 Habit: []Habit{{
775 Name: "Example Habit",
776 ID: "habit-id-placeholder",
777 }},
778 }
779 file, err := os.OpenFile(configPath, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0o600)
780 if err != nil {
781 log.Fatalf("Failed to create default config at %s: %v", configPath, err)
782 }
783 defer closeFile(file)
784 if err := toml.NewEncoder(file).Encode(defaultConfig); err != nil {
785 log.Fatalf("Failed to encode default config to %s: %v", configPath, err)
786 }
787 fmt.Printf("A default config has been created at %s.\nPlease edit it to provide your Lunatask access token, correct area/goal IDs, and your timezone (IANA/Olson format, e.g. 'America/New_York'), then restart the server.\n", configPath)
788 os.Exit(1)
789}