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	"fmt"
 10	"log"
 11	"os"
 12
 13	"github.com/BurntSushi/toml"
 14	"github.com/mark3labs/mcp-go/mcp"
 15	"github.com/mark3labs/mcp-go/server"
 16
 17	"git.sr.ht/~amolith/lunatask-mcp-server/tools"
 18)
 19
 20// Goal represents a Lunatask goal with its name and ID
 21type Goal struct {
 22	Name string `toml:"name"`
 23	ID   string `toml:"id"`
 24}
 25
 26// GetName returns the goal's name.
 27func (g Goal) GetName() string { return g.Name }
 28
 29// GetID returns the goal's ID.
 30func (g Goal) GetID() string { return g.ID }
 31
 32// Area represents a Lunatask area with its name, ID, and its goals
 33type Area struct {
 34	Name  string `toml:"name"`
 35	ID    string `toml:"id"`
 36	Goals []Goal `toml:"goals"`
 37}
 38
 39// GetName returns the area's name.
 40func (a Area) GetName() string { return a.Name }
 41
 42// GetID returns the area's ID.
 43func (a Area) GetID() string { return a.ID }
 44
 45// GetGoals returns the area's goals as a slice of tools.GoalProvider.
 46func (a Area) GetGoals() []tools.GoalProvider {
 47	providers := make([]tools.GoalProvider, len(a.Goals))
 48	for i, g := range a.Goals {
 49		providers[i] = g // Goal implements GoalProvider
 50	}
 51	return providers
 52}
 53
 54// Habit represents a Lunatask habit with its name and ID
 55type Habit struct {
 56	Name string `toml:"name"`
 57	ID   string `toml:"id"`
 58}
 59
 60// GetName returns the habit's name.
 61func (h Habit) GetName() string { return h.Name }
 62
 63// GetID returns the habit's ID.
 64func (h Habit) GetID() string { return h.ID }
 65
 66// ServerConfig holds the application's configuration loaded from TOML
 67type ServerConfig struct {
 68	Host string `toml:"host"`
 69	Port int    `toml:"port"`
 70}
 71
 72type Config struct {
 73	AccessToken string       `toml:"access_token"`
 74	Areas       []Area       `toml:"areas"`
 75	Server      ServerConfig `toml:"server"`
 76	Timezone    string       `toml:"timezone"`
 77	Habit       []Habit      `toml:"habit"`
 78}
 79
 80var version = ""
 81
 82func main() {
 83	configPath := "./config.toml"
 84	for i, arg := range os.Args {
 85		switch arg {
 86		case "-v", "--version":
 87			if version == "" {
 88				version = "unknown, build with `just build` or copy/paste the build command from ./justfile"
 89			}
 90			fmt.Println("lunatask-mcp-server:", version)
 91			os.Exit(0)
 92		case "-c", "--config":
 93			if i+1 < len(os.Args) {
 94				configPath = os.Args[i+1]
 95			}
 96		}
 97	}
 98
 99	if _, err := os.Stat(configPath); os.IsNotExist(err) {
100		createDefaultConfigFile(configPath)
101	}
102
103	var config Config
104	if _, err := toml.DecodeFile(configPath, &config); err != nil {
105		log.Fatalf("Failed to load config file %s: %v", configPath, err)
106	}
107
108	if config.AccessToken == "" || len(config.Areas) == 0 {
109		log.Fatalf("Config file must provide access_token and at least one area.")
110	}
111
112	for i, area := range config.Areas {
113		if area.Name == "" || area.ID == "" {
114			log.Fatalf("All areas (areas[%d]) must have both a name and id", i)
115		}
116		for j, goal := range area.Goals {
117			if goal.Name == "" || goal.ID == "" {
118				log.Fatalf("All goals (areas[%d].goals[%d]) must have both a name and id", i, j)
119			}
120		}
121	}
122
123	// Validate timezone config on startup
124	if _, err := tools.LoadLocation(config.Timezone); err != nil {
125		log.Fatalf("Timezone validation failed: %v", err)
126	}
127
128	mcpServer := NewMCPServer(&config)
129
130	baseURL := fmt.Sprintf("http://%s:%d", config.Server.Host, config.Server.Port)
131	sseServer := server.NewSSEServer(mcpServer, server.WithBaseURL(baseURL))
132	listenAddr := fmt.Sprintf("%s:%d", config.Server.Host, config.Server.Port)
133	log.Printf("SSE server listening on %s (baseURL: %s)", listenAddr, baseURL)
134	if err := sseServer.Start(listenAddr); err != nil {
135		log.Fatalf("Server error: %v", err)
136	}
137}
138
139// closeFile properly closes a file, handling any errors
140func closeFile(f *os.File) {
141	err := f.Close()
142	if err != nil {
143		log.Printf("Error closing file: %v", err)
144	}
145}
146
147func NewMCPServer(appConfig *Config) *server.MCPServer {
148	hooks := &server.Hooks{}
149
150	hooks.AddBeforeAny(func(ctx context.Context, id any, method mcp.MCPMethod, message any) {
151		fmt.Printf("beforeAny: %s, %v, %v\n", method, id, message)
152	})
153	hooks.AddOnSuccess(func(ctx context.Context, id any, method mcp.MCPMethod, message any, result any) {
154		fmt.Printf("onSuccess: %s, %v, %v, %v\n", method, id, message, result)
155	})
156	hooks.AddOnError(func(ctx context.Context, id any, method mcp.MCPMethod, message any, err error) {
157		fmt.Printf("onError: %s, %v, %v, %v\n", method, id, message, err)
158	})
159	hooks.AddBeforeInitialize(func(ctx context.Context, id any, message *mcp.InitializeRequest) {
160		fmt.Printf("beforeInitialize: %v, %v\n", id, message)
161	})
162	hooks.AddAfterInitialize(func(ctx context.Context, id any, message *mcp.InitializeRequest, result *mcp.InitializeResult) {
163		fmt.Printf("afterInitialize: %v, %v, %v\n", id, message, result)
164	})
165	hooks.AddAfterCallTool(func(ctx context.Context, id any, message *mcp.CallToolRequest, result *mcp.CallToolResult) {
166		fmt.Printf("afterCallTool: %v, %v, %v\n", id, message, result)
167	})
168	hooks.AddBeforeCallTool(func(ctx context.Context, id any, message *mcp.CallToolRequest) {
169		fmt.Printf("beforeCallTool: %v, %v\n", id, message)
170	})
171
172	mcpServer := server.NewMCPServer(
173		"Lunatask MCP Server",
174		"0.1.0",
175		server.WithHooks(hooks),
176		server.WithToolCapabilities(true),
177	)
178
179	// Prepare AreaProviders
180	areaProviders := make([]tools.AreaProvider, len(appConfig.Areas))
181	for i, area := range appConfig.Areas {
182		areaProviders[i] = area // Area implements AreaProvider
183	}
184
185	// Prepare HabitProviders
186	habitProviders := make([]tools.HabitProvider, len(appConfig.Habit))
187	for i, habit := range appConfig.Habit {
188		habitProviders[i] = habit // Habit implements HabitProvider
189	}
190
191	handlerCfg := tools.HandlerConfig{
192		AccessToken: appConfig.AccessToken,
193		Timezone:    appConfig.Timezone,
194		Areas:       areaProviders,
195		Habits:      habitProviders,
196	}
197	toolHandlers := tools.NewHandlers(handlerCfg)
198
199	mcpServer.AddTool(mcp.NewTool("get_timestamp",
200		mcp.WithDescription("Retrieves the formatted timestamp for a task"),
201		mcp.WithString("natural_language_date",
202			mcp.Description("Natural language date as described by the user, e.g. '1 week', 'tomorrow', 'sunday at 19:00', 'now', etc."),
203			mcp.Required(),
204		),
205	), toolHandlers.HandleGetTimestamp)
206
207	mcpServer.AddTool(
208		mcp.NewTool(
209			"list_areas_and_goals",
210			mcp.WithDescription("List areas and goals and their IDs."),
211		),
212		toolHandlers.HandleListAreasAndGoals,
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.WithString("priority",
237			mcp.Description("Task priority, omit unless priority is mentioned"),
238			mcp.Enum("lowest", "low", "neutral", "high", "highest"),
239		),
240		mcp.WithString("motivation",
241			mcp.Description("Motivation driving task creation"),
242			mcp.Enum("must", "should", "want"),
243		),
244		mcp.WithString("status",
245			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."),
246			mcp.Enum("later", "next", "started", "waiting", "completed"),
247		),
248		mcp.WithString("scheduled_on",
249			mcp.Description("Formatted timestamp from get_task_timestamp tool"),
250		),
251	), toolHandlers.HandleCreateTask)
252
253	mcpServer.AddTool(mcp.NewTool("update_task",
254		mcp.WithDescription("Updates an existing task. Only provided fields will be targeted for update."),
255		mcp.WithString("task_id",
256			mcp.Description("ID of the task to update."),
257			mcp.Required(),
258		),
259		mcp.WithString("area_id",
260			mcp.Description("New Area ID for the task. Must be a valid Area ID from 'list_areas_and_goals'."),
261		),
262		mcp.WithString("goal_id",
263			mcp.Description("New Goal ID for the task. Must be a valid Goal ID from 'list_areas_and_goals'."),
264		),
265		mcp.WithString("name",
266			mcp.Description("New plain text task name using sentence case. Sending an empty string WILL clear the name."),
267			mcp.Required(),
268		),
269		mcp.WithString("note",
270			mcp.Description("New note attached to the task, optionally Markdown-formatted. Sending an empty string WILL clear the note."),
271		),
272		mcp.WithNumber("estimate",
273			mcp.Description("New estimated time completion time in minutes."),
274			mcp.Min(0),
275			mcp.Max(720), // Aligned with CreateTaskRequest validation tag
276		),
277		mcp.WithString("priority",
278			mcp.Description("Task priority, omit unless priority is mentioned"),
279			mcp.Enum("lowest", "low", "neutral", "high", "highest"),
280		),
281		mcp.WithString("motivation",
282			mcp.Description("New motivation driving the task."),
283			mcp.Enum("must", "should", "want", ""), // Allow empty string to potentially clear/unset
284		),
285		mcp.WithString("status",
286			mcp.Description("New task state."),
287			mcp.Enum("later", "next", "started", "waiting", "completed", ""), // Allow empty string
288		),
289		mcp.WithString("scheduled_on",
290			mcp.Description("New scheduled date/time as a formatted timestamp from get_task_timestamp tool. Sending an empty string might clear the scheduled date."),
291		),
292	), toolHandlers.HandleUpdateTask)
293
294	mcpServer.AddTool(mcp.NewTool("delete_task",
295		mcp.WithDescription("Deletes an existing task"),
296		mcp.WithString("task_id",
297			mcp.Description("ID of the task to delete."),
298			mcp.Required(),
299		),
300	), toolHandlers.HandleDeleteTask)
301
302	mcpServer.AddTool(
303		mcp.NewTool(
304			"list_habits_and_activities",
305			mcp.WithDescription("List habits and their IDs for tracking or marking complete with tracking_habit_activity"),
306		),
307		toolHandlers.HandleListHabitsAndActivities,
308	)
309
310	mcpServer.AddTool(mcp.NewTool("track_habit_activity",
311		mcp.WithDescription("Tracks an activity or a habit in Lunatask"),
312		mcp.WithString("habit_id",
313			mcp.Description("ID of the habit to track activity for."),
314			mcp.Required(),
315		),
316		mcp.WithString("performed_on",
317			mcp.Description("The timestamp the habit was performed, first obtained with get_timestamp."),
318			mcp.Required(),
319		),
320	), toolHandlers.HandleTrackHabitActivity)
321
322	return mcpServer
323}
324
325func createDefaultConfigFile(configPath string) {
326	defaultConfig := Config{
327		Server: ServerConfig{
328			Host: "localhost",
329			Port: 8080,
330		},
331		AccessToken: "",
332		Timezone:    "UTC",
333		Areas: []Area{{
334			Name: "Example Area",
335			ID:   "area-id-placeholder",
336			Goals: []Goal{{
337				Name: "Example Goal",
338				ID:   "goal-id-placeholder",
339			}},
340		}},
341		Habit: []Habit{{
342			Name: "Example Habit",
343			ID:   "habit-id-placeholder",
344		}},
345	}
346	file, err := os.OpenFile(configPath, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0o600)
347	if err != nil {
348		log.Fatalf("Failed to create default config at %s: %v", configPath, err)
349	}
350	defer closeFile(file)
351	if err := toml.NewEncoder(file).Encode(defaultConfig); err != nil {
352		log.Fatalf("Failed to encode default config to %s: %v", configPath, err)
353	}
354	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)
355	os.Exit(1)
356}