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}