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}