main.go

  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}