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
 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.WithString("priority",
286			mcp.Description("Task priority, omit unless priority is mentioned"),
287			mcp.Enum("lowest", "low", "neutral", "high", "highest"),
288		),
289		mcp.WithString("motivation",
290			mcp.Description("New motivation driving the task."),
291			mcp.Enum("must", "should", "want", ""), // Allow empty string to potentially clear/unset
292		),
293		mcp.WithString("status",
294			mcp.Description("New task state."),
295			mcp.Enum("later", "next", "started", "waiting", "completed", ""), // Allow empty string
296		),
297		mcp.WithString("scheduled_on",
298			mcp.Description("New scheduled date/time as a formatted timestamp from get_task_timestamp tool. Sending an empty string might clear the scheduled date."),
299		),
300	), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
301		return handleUpdateTask(ctx, request, config)
302	})
303
304	mcpServer.AddTool(mcp.NewTool("delete_task",
305		mcp.WithDescription("Deletes an existing task"),
306		mcp.WithString("task_id",
307			mcp.Description("ID of the task to delete."),
308			mcp.Required(),
309		),
310	), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
311		return handleDeleteTask(ctx, request, config)
312	})
313
314	mcpServer.AddTool(
315		mcp.NewTool(
316			"list_habits_and_activities",
317			mcp.WithDescription("List habits and their IDs for tracking or marking complete with tracking_habit_activity"),
318		),
319		func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
320			var b strings.Builder
321			for _, habit := range config.Habit {
322				fmt.Fprintf(&b, "- %s: %s\n", habit.Name, habit.ID)
323			}
324			return &mcp.CallToolResult{
325				Content: []mcp.Content{
326					mcp.TextContent{
327						Type: "text",
328						Text: b.String(),
329					},
330				},
331			}, nil
332		},
333	)
334
335	mcpServer.AddTool(mcp.NewTool("track_habit_activity",
336		mcp.WithDescription("Tracks an activity or a habit in Lunatask"),
337		mcp.WithString("habit_id",
338			mcp.Description("ID of the habit to track activity for."),
339			mcp.Required(),
340		),
341		mcp.WithString("performed_on",
342			mcp.Description("The timestamp the habit was performed, first obtained with get_timestamp."),
343			mcp.Required(),
344		),
345	), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
346		return handleTrackHabitActivity(ctx, request, config)
347	})
348
349	return mcpServer
350}
351
352func reportMCPError(msg string) (*mcp.CallToolResult, error) {
353	return &mcp.CallToolResult{
354		IsError: true,
355		Content: []mcp.Content{mcp.TextContent{Type: "text", Text: msg}},
356	}, nil
357}
358
359// handleCreateTask handles the creation of a task in Lunatask
360func handleCreateTask(
361	ctx context.Context,
362	request mcp.CallToolRequest,
363	config *Config,
364) (*mcp.CallToolResult, error) {
365	arguments := request.Params.Arguments
366
367	// Validate timezone before proceeding any further
368	if _, err := loadLocation(config.Timezone); err != nil {
369		return reportMCPError(err.Error())
370	}
371
372	areaID, ok := arguments["area_id"].(string)
373	if !ok || areaID == "" {
374		return reportMCPError("Missing or invalid required argument: area_id")
375	}
376
377	var area *Area
378	for i := range config.Areas {
379		if config.Areas[i].ID == areaID {
380			area = &config.Areas[i]
381			break
382		}
383	}
384	if area == nil {
385		return reportMCPError("Area not found for given area_id")
386	}
387
388	if goalID, exists := arguments["goal_id"].(string); exists && goalID != "" {
389		found := false
390		for _, goal := range area.Goals {
391			if goal.ID == goalID {
392				found = true
393				break
394			}
395		}
396		if !found {
397			return reportMCPError("Goal not found in specified area for given goal_id")
398		}
399	}
400
401	// Priority translation and validation
402	priorityMap := map[string]int{
403		"lowest":  -2,
404		"low":     -1,
405		"neutral": 0,
406		"high":    1,
407		"highest": 2,
408	}
409
410	if priorityArg, exists := arguments["priority"]; exists && priorityArg != nil {
411		priorityStr, ok := priorityArg.(string)
412		if !ok {
413			// This should ideally be caught by MCP schema validation if type is string.
414			return reportMCPError("Invalid type for 'priority' argument: expected string.")
415		}
416		// An empty string for priority is not valid as it's not in the enum.
417		// The map lookup will fail for an empty string, triggering the !isValid block.
418
419		translatedPriority, isValid := priorityMap[strings.ToLower(priorityStr)]
420		if !isValid {
421			return reportMCPError(fmt.Sprintf("Invalid 'priority' value: '%s'. Must be one of 'lowest', 'low', 'neutral', 'high', 'highest'.", priorityStr))
422		}
423		arguments["priority"] = translatedPriority // Update the map with the integer value
424	}
425
426	if motivationVal, exists := arguments["motivation"]; exists && motivationVal != nil {
427		if motivation, ok := motivationVal.(string); ok && motivation != "" {
428			validMotivations := map[string]bool{"must": true, "should": true, "want": true}
429			if !validMotivations[motivation] {
430				return reportMCPError("'motivation' must be one of 'must', 'should', or 'want'")
431			}
432		} else if ok {
433			// empty string is allowed
434		} else {
435			return reportMCPError("'motivation' must be a string")
436		}
437	}
438
439	if statusVal, exists := arguments["status"]; exists && statusVal != nil {
440		if status, ok := statusVal.(string); ok && status != "" {
441			validStatus := map[string]bool{"later": true, "next": true, "started": true, "waiting": true, "completed": true}
442			if !validStatus[status] {
443				return reportMCPError("'status' must be one of 'later', 'next', 'started', 'waiting', or 'completed'")
444			}
445		} else if ok {
446			// empty string is allowed
447		} else {
448			return reportMCPError("'status' must be a string")
449		}
450	}
451
452	// Validate scheduled_on format if provided
453	if scheduledOnArg, exists := arguments["scheduled_on"]; exists {
454		if scheduledOnStr, ok := scheduledOnArg.(string); ok && scheduledOnStr != "" {
455			if _, err := time.Parse(time.RFC3339, scheduledOnStr); err != nil {
456				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))
457			}
458		} else if !ok {
459			// It exists but isn't a string, which shouldn't happen based on MCP schema but check anyway
460			return reportMCPError("Invalid type for scheduled_on argument: expected string.")
461		}
462		// If it's an empty string, it's handled by the API or omitempty later, no need to validate format.
463	}
464
465	// Create Lunatask client
466	client := lunatask.NewClient(config.AccessToken)
467
468	// Prepare the task request
469	var task lunatask.CreateTaskRequest
470	argBytes, err := json.Marshal(arguments)
471	if err != nil {
472		return reportMCPError(fmt.Sprintf("Failed to process arguments: %v", err))
473	}
474	if err := json.Unmarshal(argBytes, &task); err != nil {
475		return reportMCPError(fmt.Sprintf("Failed to parse arguments: %v", err))
476	}
477
478	// Call the client to create the task
479	response, err := client.CreateTask(ctx, &task)
480	if err != nil {
481		return reportMCPError(fmt.Sprintf("%v", err))
482	}
483
484	// Handle the case where task already exists
485	if response == nil {
486		return &mcp.CallToolResult{
487			Content: []mcp.Content{
488				mcp.TextContent{
489					Type: "text",
490					Text: "Task already exists (not an error).",
491				},
492			},
493		}, nil
494	}
495
496	return &mcp.CallToolResult{
497		Content: []mcp.Content{
498			mcp.TextContent{
499				Type: "text",
500				Text: fmt.Sprintf("Task created successfully with ID: %s", response.Task.ID),
501			},
502		},
503	}, nil
504}
505
506// handleUpdateTask handles the update of a task in Lunatask
507func handleUpdateTask(
508	ctx context.Context,
509	request mcp.CallToolRequest,
510	config *Config,
511) (*mcp.CallToolResult, error) {
512	arguments := request.Params.Arguments
513
514	taskID, ok := arguments["task_id"].(string)
515	if !ok || taskID == "" {
516		return reportMCPError("Missing or invalid required argument: task_id")
517	}
518
519	// Validate timezone before proceeding, as it might be used by API or for scheduled_on
520	if _, err := loadLocation(config.Timezone); err != nil {
521		return reportMCPError(err.Error())
522	}
523
524	updatePayload := lunatask.CreateTaskRequest{} // Reusing CreateTaskRequest for the update body
525
526	var specifiedArea *Area // Used for goal validation if area_id is also specified
527	areaIDProvided := false
528
529	if areaIDArg, exists := arguments["area_id"]; exists {
530		if areaIDStr, ok := areaIDArg.(string); ok && areaIDStr != "" {
531			updatePayload.AreaID = areaIDStr
532			areaIDProvided = true
533			found := false
534			for i := range config.Areas {
535				if config.Areas[i].ID == areaIDStr {
536					specifiedArea = &config.Areas[i]
537					found = true
538					break
539				}
540			}
541			if !found {
542				return reportMCPError(fmt.Sprintf("Area not found for given area_id: %s", areaIDStr))
543			}
544		} else if !ok && areaIDArg != nil { // Exists but not a string
545			return reportMCPError("Invalid type for area_id argument: expected string.")
546		}
547		// If areaIDArg is an empty string or nil, it's fine, AreaID in payload will be "" (or not set if using pointers/map)
548		// With CreateTaskRequest, it will be "" if not explicitly set to a non-empty string.
549	}
550
551	if goalIDArg, exists := arguments["goal_id"]; exists {
552		if goalIDStr, ok := goalIDArg.(string); ok && goalIDStr != "" {
553			updatePayload.GoalID = goalIDStr
554			// If goal_id is specified, but area_id is not, we cannot validate the goal against a specific area from config.
555			// The API will have to handle this. For stricter local validation, one might require area_id here.
556			// For now, we proceed, assuming the API can handle it or the goal is in the task's current (unchanged) area.
557			// If area_id WAS provided, specifiedArea would be set.
558			// If area_id was NOT provided, we need to check all areas, or rely on API.
559			// Let's enforce that if goal_id is given, and area_id is also given, the goal must be in that area.
560			// If goal_id is given and area_id is NOT, we can't validate locally.
561			// The description for goal_id parameter hints at this.
562			if specifiedArea != nil { // Only validate goal if its intended area (new or existing) is known
563				foundGoal := false
564				for _, goal := range specifiedArea.Goals {
565					if goal.ID == goalIDStr {
566						foundGoal = true
567						break
568					}
569				}
570				if !foundGoal {
571					return reportMCPError(fmt.Sprintf("Goal not found in specified area '%s' for given goal_id: %s", specifiedArea.Name, goalIDStr))
572				}
573			} else if areaIDProvided { // area_id was provided but somehow specifiedArea is nil (should be caught above)
574				return reportMCPError("Internal error: area_id provided but area details not loaded for goal validation.")
575			}
576		} else if !ok && goalIDArg != nil {
577			return reportMCPError("Invalid type for goal_id argument: expected string.")
578		}
579	}
580
581	// Name is now required by the MCP tool definition.
582	// The lunatask.ValidateTask (called by client.UpdateTask) will ensure it's not an empty string
583	// due to the "required" tag on CreateTaskRequest.Name.
584	nameArg := arguments["name"] // MCP framework ensures "name" exists.
585	if nameStr, ok := nameArg.(string); ok {
586		updatePayload.Name = nameStr
587	} else {
588		// This case should ideally be caught by MCP's type checking.
589		// A defensive check is good.
590		return reportMCPError("Invalid type for name argument: expected string.")
591	}
592
593	if noteArg, exists := arguments["note"]; exists {
594		if noteStr, ok := noteArg.(string); ok {
595			updatePayload.Note = noteStr
596		} else if !ok && noteArg != nil {
597			return reportMCPError("Invalid type for note argument: expected string.")
598		}
599	}
600
601	if estimateArg, exists := arguments["estimate"]; exists && estimateArg != nil {
602		if estimateVal, ok := estimateArg.(float64); ok {
603			// Validation for min/max (0-720) is in CreateTaskRequest struct tags,
604			// checked by lunatask.ValidateTask.
605			// MCP tool also defines this range.
606			updatePayload.Estimate = int(estimateVal)
607		} else {
608			return reportMCPError("Invalid type for estimate argument: expected number.")
609		}
610	}
611
612	if priorityArg, exists := arguments["priority"]; exists && priorityArg != nil {
613		priorityStr, ok := priorityArg.(string)
614		if !ok {
615			return reportMCPError("Invalid type for 'priority' argument: expected string.")
616		}
617		priorityMap := map[string]int{
618			"lowest":  -2,
619			"low":     -1,
620			"neutral": 0,
621			"high":    1,
622			"highest": 2,
623		}
624		translatedPriority, isValid := priorityMap[strings.ToLower(priorityStr)]
625		if !isValid {
626			return reportMCPError(fmt.Sprintf("Invalid 'priority' value: '%s'. Must be one of 'lowest', 'low', 'neutral', 'high', 'highest'.", priorityStr))
627		}
628		updatePayload.Priority = translatedPriority
629	}
630
631	if motivationArg, exists := arguments["motivation"]; exists {
632		if motivationStr, ok := motivationArg.(string); ok {
633			if motivationStr != "" { // Allow empty string to be passed if desired (e.g. to clear)
634				validMotivations := map[string]bool{"must": true, "should": true, "want": true}
635				if !validMotivations[motivationStr] {
636					return reportMCPError("'motivation' must be one of 'must', 'should', or 'want', or empty to clear.")
637				}
638			}
639			updatePayload.Motivation = motivationStr
640		} else if !ok && motivationArg != nil {
641			return reportMCPError("Invalid type for motivation argument: expected string.")
642		}
643	}
644
645	if statusArg, exists := arguments["status"]; exists {
646		if statusStr, ok := statusArg.(string); ok {
647			if statusStr != "" { // Allow empty string
648				validStatus := map[string]bool{"later": true, "next": true, "started": true, "waiting": true, "completed": true}
649				if !validStatus[statusStr] {
650					return reportMCPError("'status' must be one of 'later', 'next', 'started', 'waiting', 'completed', or empty.")
651				}
652			}
653			updatePayload.Status = statusStr
654		} else if !ok && statusArg != nil {
655			return reportMCPError("Invalid type for status argument: expected string.")
656		}
657	}
658
659	if scheduledOnArg, exists := arguments["scheduled_on"]; exists {
660		if scheduledOnStr, ok := scheduledOnArg.(string); ok {
661			if scheduledOnStr != "" { // Allow empty string to potentially clear scheduled_on
662				if _, err := time.Parse(time.RFC3339, scheduledOnStr); err != nil {
663					return reportMCPError(fmt.Sprintf("Invalid format for scheduled_on: '%s'. Must be RFC3339. Use get_task_timestamp tool.", scheduledOnStr))
664				}
665			}
666			updatePayload.ScheduledOn = scheduledOnStr
667		} else if !ok && scheduledOnArg != nil {
668			return reportMCPError("Invalid type for scheduled_on argument: expected string.")
669		}
670	}
671
672	// Create Lunatask client
673	client := lunatask.NewClient(config.AccessToken)
674
675	// Call the client to update the task
676	// The updatePayload (CreateTaskRequest) will be validated by client.UpdateTask->ValidateTask
677	response, err := client.UpdateTask(ctx, taskID, &updatePayload)
678	if err != nil {
679		return reportMCPError(fmt.Sprintf("Failed to update task: %v", err))
680	}
681
682	// The API returns the updated task details.
683	// We can construct a more detailed message if needed, e.g., by marshaling response.Task to JSON.
684	return &mcp.CallToolResult{
685		Content: []mcp.Content{
686			mcp.TextContent{
687				Type: "text",
688				Text: fmt.Sprintf("Task updated successfully. ID: %s", response.Task.ID),
689			},
690		},
691	}, nil
692}
693
694func handleDeleteTask(
695	ctx context.Context,
696	request mcp.CallToolRequest,
697	config *Config,
698) (*mcp.CallToolResult, error) {
699	taskID, ok := request.Params.Arguments["task_id"].(string)
700	if !ok || taskID == "" {
701		return reportMCPError("Missing or invalid required argument: task_id")
702	}
703
704	// Create the Lunatask client
705	client := lunatask.NewClient(config.AccessToken)
706
707	// Delete the task
708	_, err := client.DeleteTask(ctx, taskID)
709	if err != nil {
710		return reportMCPError(fmt.Sprintf("Failed to delete task: %v", err))
711	}
712
713	// Return success response
714	return &mcp.CallToolResult{
715		Content: []mcp.Content{
716			mcp.TextContent{
717				Type: "text",
718				Text: "Task deleted successfully.",
719			},
720		},
721	}, nil
722}
723
724// handleTrackHabitActivity handles tracking a habit activity in Lunatask
725func handleTrackHabitActivity(
726	ctx context.Context,
727	request mcp.CallToolRequest,
728	config *Config,
729) (*mcp.CallToolResult, error) {
730	habitID, ok := request.Params.Arguments["habit_id"].(string)
731	if !ok || habitID == "" {
732		return reportMCPError("Missing or invalid required argument: habit_id")
733	}
734
735	performedOn, ok := request.Params.Arguments["performed_on"].(string)
736	if !ok || performedOn == "" {
737		return reportMCPError("Missing or invalid required argument: performed_on")
738	}
739
740	// Create the Lunatask client
741	client := lunatask.NewClient(config.AccessToken)
742
743	// Create the request
744	habitRequest := &lunatask.TrackHabitActivityRequest{
745		PerformedOn: performedOn,
746	}
747
748	// Track the habit activity
749	resp, err := client.TrackHabitActivity(ctx, habitID, habitRequest)
750	if err != nil {
751		return reportMCPError(fmt.Sprintf("Failed to track habit activity: %v", err))
752	}
753
754	// Return success response
755	return &mcp.CallToolResult{
756		Content: []mcp.Content{
757			mcp.TextContent{
758				Type: "text",
759				Text: fmt.Sprintf("Habit activity tracked successfully. Status: %s, Message: %s", resp.Status, resp.Message),
760			},
761		},
762	}, nil
763}
764
765func createDefaultConfigFile(configPath string) {
766	defaultConfig := Config{
767		Server: ServerConfig{
768			Host: "localhost",
769			Port: 8080,
770		},
771		AccessToken: "",
772		Timezone:    "UTC",
773		Areas: []Area{{
774			Name: "Example Area",
775			ID:   "area-id-placeholder",
776			Goals: []Goal{{
777				Name: "Example Goal",
778				ID:   "goal-id-placeholder",
779			}},
780		}},
781		Habit: []Habit{{
782			Name: "Example Habit",
783			ID:   "habit-id-placeholder",
784		}},
785	}
786	file, err := os.OpenFile(configPath, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0o600)
787	if err != nil {
788		log.Fatalf("Failed to create default config at %s: %v", configPath, err)
789	}
790	defer closeFile(file)
791	if err := toml.NewEncoder(file).Encode(defaultConfig); err != nil {
792		log.Fatalf("Failed to encode default config to %s: %v", configPath, err)
793	}
794	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)
795	os.Exit(1)
796}