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