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