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 "net"
12 "os"
13 "strconv"
14
15 "github.com/BurntSushi/toml"
16 "github.com/mark3labs/mcp-go/mcp"
17 "github.com/mark3labs/mcp-go/server"
18
19 "git.sr.ht/~amolith/lunatask-mcp-server/tools"
20)
21
22// Goal represents a Lunatask goal with its name and ID.
23type Goal struct {
24 Name string `toml:"name"`
25 ID string `toml:"id"`
26}
27
28// GetName returns the goal's name.
29func (g Goal) GetName() string { return g.Name }
30
31// GetID returns the goal's ID.
32func (g Goal) GetID() string { return g.ID }
33
34// Area represents a Lunatask area with its name, ID, and its goals.
35type Area struct {
36 Name string `toml:"name"`
37 ID string `toml:"id"`
38 Goals []Goal `toml:"goal"`
39}
40
41// GetName returns the area's name.
42func (a Area) GetName() string { return a.Name }
43
44// GetID returns the area's ID.
45func (a Area) GetID() string { return a.ID }
46
47// GetGoals returns the area's goals as a slice of tools.GoalProvider.
48func (a Area) GetGoals() []tools.GoalProvider {
49 providers := make([]tools.GoalProvider, len(a.Goals))
50 for i, g := range a.Goals {
51 providers[i] = g // Goal implements GoalProvider
52 }
53
54 return providers
55}
56
57// Habit represents a Lunatask habit with its name and ID.
58type Habit struct {
59 Name string `toml:"name"`
60 ID string `toml:"id"`
61}
62
63// GetName returns the habit's name.
64func (h Habit) GetName() string { return h.Name }
65
66// GetID returns the habit's ID.
67func (h Habit) GetID() string { return h.ID }
68
69// ServerConfig holds the application's configuration loaded from TOML.
70type ServerConfig struct {
71 Host string `toml:"host"`
72 Port int `toml:"port"`
73}
74
75type Config struct {
76 AccessToken string `toml:"access_token"`
77 Areas []Area `toml:"area"`
78 Server ServerConfig `toml:"server"`
79 Timezone string `toml:"timezone"`
80 Habit []Habit `toml:"habit"`
81}
82
83var version = ""
84
85func main() {
86 configPath := "./config.toml"
87
88 for i, arg := range os.Args {
89 switch arg {
90 case "-v", "--version":
91 if version == "" {
92 version = "unknown, build with `just build` or copy/paste the build command from ./justfile"
93 }
94
95 fmt.Println("lunatask-mcp-server:", version)
96 os.Exit(0)
97 case "-c", "--config":
98 if i+1 < len(os.Args) {
99 configPath = os.Args[i+1]
100 }
101 }
102 }
103
104 if _, err := os.Stat(configPath); os.IsNotExist(err) {
105 createDefaultConfigFile(configPath)
106 }
107
108 var config Config
109 if _, err := toml.DecodeFile(configPath, &config); err != nil {
110 log.Fatalf("Failed to load config file %s: %v", configPath, err)
111 }
112
113 if config.AccessToken == "" || len(config.Areas) == 0 {
114 log.Fatalf("Config file must provide access_token and at least one area.")
115 }
116
117 for i, area := range config.Areas {
118 if area.Name == "" || area.ID == "" {
119 log.Fatalf("All areas (areas[%d]) must have both a name and id", i)
120 }
121
122 for j, goal := range area.Goals {
123 if goal.Name == "" || goal.ID == "" {
124 log.Fatalf("All goals (areas[%d].goals[%d]) must have both a name and id", i, j)
125 }
126 }
127 }
128
129 // Validate timezone config on startup
130 if _, err := tools.LoadLocation(config.Timezone); err != nil {
131 log.Fatalf("Timezone validation failed: %v", err)
132 }
133
134 mcpServer := NewMCPServer(&config)
135
136 hostPort := net.JoinHostPort(config.Server.Host, strconv.Itoa(config.Server.Port))
137 baseURL := "http://" + hostPort
138 sseServer := server.NewSSEServer(mcpServer, server.WithBaseURL(baseURL))
139 listenAddr := hostPort
140 log.Printf("SSE server listening on %s (baseURL: %s)", listenAddr, baseURL)
141
142 if err := sseServer.Start(listenAddr); err != nil {
143 log.Fatalf("Server error: %v", err)
144 }
145}
146
147// closeFile properly closes a file, handling any errors.
148func closeFile(f *os.File) {
149 err := f.Close()
150 if err != nil {
151 log.Printf("Error closing file: %v", err)
152 }
153}
154
155func NewMCPServer(appConfig *Config) *server.MCPServer {
156 hooks := &server.Hooks{}
157
158 hooks.AddBeforeAny(func(ctx context.Context, id any, method mcp.MCPMethod, message any) {
159 fmt.Printf("beforeAny: %s, %v, %v\n", method, id, message)
160 })
161 hooks.AddOnSuccess(func(ctx context.Context, id any, method mcp.MCPMethod, message any, result any) {
162 fmt.Printf("onSuccess: %s, %v, %v, %v\n", method, id, message, result)
163 })
164 hooks.AddOnError(func(ctx context.Context, id any, method mcp.MCPMethod, message any, err error) {
165 fmt.Printf("onError: %s, %v, %v, %v\n", method, id, message, err)
166 })
167 hooks.AddBeforeInitialize(func(ctx context.Context, id any, message *mcp.InitializeRequest) {
168 fmt.Printf("beforeInitialize: %v, %v\n", id, message)
169 })
170 hooks.AddAfterInitialize(func(ctx context.Context, id any, message *mcp.InitializeRequest, result *mcp.InitializeResult) {
171 fmt.Printf("afterInitialize: %v, %v, %v\n", id, message, result)
172 })
173 hooks.AddAfterCallTool(func(ctx context.Context, id any, message *mcp.CallToolRequest, result *mcp.CallToolResult) {
174 fmt.Printf("afterCallTool: %v, %v, %v\n", id, message, result)
175 })
176 hooks.AddBeforeCallTool(func(ctx context.Context, id any, message *mcp.CallToolRequest) {
177 fmt.Printf("beforeCallTool: %v, %v\n", id, message)
178 })
179
180 mcpServer := server.NewMCPServer(
181 "Lunatask MCP Server",
182 "0.1.0",
183 server.WithHooks(hooks),
184 server.WithToolCapabilities(true),
185 )
186
187 // Prepare AreaProviders
188 areaProviders := make([]tools.AreaProvider, len(appConfig.Areas))
189 for i, area := range appConfig.Areas {
190 areaProviders[i] = area // Area implements AreaProvider
191 }
192
193 // Prepare HabitProviders
194 habitProviders := make([]tools.HabitProvider, len(appConfig.Habit))
195 for i, habit := range appConfig.Habit {
196 habitProviders[i] = habit // Habit implements HabitProvider
197 }
198
199 handlerCfg := tools.HandlerConfig{
200 AccessToken: appConfig.AccessToken,
201 Timezone: appConfig.Timezone,
202 Areas: areaProviders,
203 Habits: habitProviders,
204 }
205 toolHandlers := tools.NewHandlers(handlerCfg)
206
207 mcpServer.AddTool(mcp.NewTool("get_timestamp",
208 mcp.WithDescription("Parses natural language dates into formatted timestamps for task scheduling. Use this tool when creating or updating tasks that include dates or times (after using list_areas_and_goals first). Supports relative dates (e.g., 'tomorrow', '1 week', 'next friday'), absolute dates (e.g., 'january 15', '2024-03-10'), and times (e.g., 'at 2pm', 'sunday at 19:00'). Also accepts 'now' for current timestamp."),
209 mcp.WithString("natural_language_date",
210 mcp.Description("Natural language date expression. Examples: 'tomorrow', '1 week', 'sunday at 19:00', 'january 15 at 2pm', 'next friday', 'now'. The tool will parse this into a properly formatted timestamp for use with task scheduling."),
211 mcp.Required(),
212 ),
213 ), toolHandlers.HandleGetTimestamp)
214
215 mcpServer.AddTool(
216 mcp.NewTool(
217 "list_areas_and_goals",
218 mcp.WithDescription("Lists all available areas and their associated goals with their IDs. Use this tool FIRST before creating or updating tasks to identify valid area_id and goal_id values. Areas represent broad categories of work, and goals are specific objectives within those areas. Each task must belong to an area and can optionally be associated with a goal within that area."),
219 ),
220 toolHandlers.HandleListAreasAndGoals,
221 )
222
223 mcpServer.AddTool(mcp.NewTool("create_task",
224 mcp.WithDescription("Creates a new task in Lunatask. WORKFLOW: First use list_areas_and_goals to identify valid area_id and goal_id values, then use get_timestamp if scheduling the task. Only include optional parameters if the user indicates or hints at them. Try to interpret speech-to-text input that may not be entirely accurate."),
225 mcp.WithString("area_id",
226 mcp.Description("Area ID in which to create the task. Must be a valid area_id from list_areas_and_goals tool."),
227 mcp.Required(),
228 ),
229 mcp.WithString("goal_id",
230 mcp.Description("Optional goal ID to associate the task with. Must be a valid goal_id from list_areas_and_goals that belongs to the specified area. Only include if the task relates to a specific goal."),
231 ),
232 mcp.WithString("name",
233 mcp.Description("Plain text task name using sentence case."),
234 mcp.Required(),
235 ),
236 mcp.WithString("note",
237 mcp.Description("Additional details or notes for the task, using Markdown formatting. Include any extra context, requirements, or information provided by the user that doesn't fit in the task name."),
238 ),
239 mcp.WithNumber("estimate",
240 mcp.Description("Estimated completion time in minutes (0-720, max 12 hours). Only include if user mentions a time estimate like '30 minutes' (pass 30) or '2 hours' (pass 120). Omit if no estimate is provided."),
241 mcp.Min(0),
242 mcp.Max(720),
243 ),
244 mcp.WithString("priority",
245 mcp.Description("Task priority level. Valid values: 'lowest', 'low', 'neutral', 'high', 'highest'. Only include if user explicitly mentions priority or urgency. Omit for normal tasks."),
246 mcp.Enum("lowest", "low", "neutral", "high", "highest"),
247 ),
248 mcp.WithString("motivation",
249 mcp.Description("Level of importance for the task. Valid values: 'must' (critical/required), 'should' (important), 'want' (nice-to-have). Only include if the user's language suggests strong obligation ('I need to', 'I have to') vs preference ('I'd like to', 'I want to')."),
250 mcp.Enum("must", "should", "want"),
251 ),
252 mcp.WithString("eisenhower",
253 mcp.Description("Eisenhower Matrix quadrant for task prioritization. Valid values: 'both urgent and important', 'urgent, but not important', 'important, but not urgent', 'neither urgent nor important', 'uncategorised'. Only include for areas which the user has indicated follow the Eisenhower workflow."),
254 mcp.Enum("both urgent and important", "urgent, but not important", "important, but not urgent", "neither urgent nor important", "uncategorised"),
255 ),
256 mcp.WithString("status",
257 mcp.Description("Initial task status. Valid values: 'later' (someday/backlog), 'next' (upcoming/soon), 'started' (in progress), 'waiting' (blocked), 'completed' (finished). Infer from context: 'working on' = 'started', 'soon'/'upcoming' = 'next', 'blocked'/'waiting for' = 'waiting'. Omit for normal new tasks (defaults to appropriate status)."),
258 mcp.Enum("later", "next", "started", "waiting", "completed"),
259 ),
260 mcp.WithString("scheduled_on",
261 mcp.Description("Scheduled date/time for the task. Must use the formatted timestamp returned by get_timestamp tool. Only include if user specifies when the task should be done."),
262 ),
263 ), toolHandlers.HandleCreateTask)
264
265 mcpServer.AddTool(mcp.NewTool("update_task",
266 mcp.WithDescription("Updates an existing task. Only provided fields will be updated. WORKFLOW: Use list_areas_and_goals first if changing area/goal, then get_timestamp if changing schedule. Only include parameters that are being changed. Empty strings will clear existing values for text fields."),
267 mcp.WithString("task_id",
268 mcp.Description("ID of the task to update."),
269 mcp.Required(),
270 ),
271 mcp.WithString("area_id",
272 mcp.Description("New Area ID for the task. Must be a valid area_id from list_areas_and_goals tool. Only include if moving the task to a different area. If omitted, the task will remain in its current area."),
273 ),
274 mcp.WithString("goal_id",
275 mcp.Description("New Goal ID for the task. Must be a valid goal_id from list_areas_and_goals that belongs to the task's area (current or new). Only include if changing the goal association."),
276 ),
277 mcp.WithString("name",
278 mcp.Description("New plain text task name using sentence case. Sending an empty string WILL clear the name."),
279 mcp.Required(),
280 ),
281 mcp.WithString("note",
282 mcp.Description("New note for the task, using Markdown formatting. Sending an empty string WILL clear the existing note. Only include if changing the task notes."),
283 ),
284 mcp.WithNumber("estimate",
285 mcp.Description("New estimated completion time in minutes (0-720, max 12 hours). Only include if user mentions changing the time estimate. Note: update_task has a lower maximum than create_task."),
286 mcp.Min(0),
287 mcp.Max(720),
288 ),
289 mcp.WithString("priority",
290 mcp.Description("New task priority level. Valid values: 'lowest', 'low', 'neutral', 'high', 'highest'. Only include if user wants to change the priority."),
291 mcp.Enum("lowest", "low", "neutral", "high", "highest"),
292 ),
293 mcp.WithString("motivation",
294 mcp.Description("New level of importance for the task. Valid values: 'must' (critical/required), 'should' (important), 'want' (nice-to-have), or empty string to clear. Only include if changing the motivation level."),
295 mcp.Enum("must", "should", "want", ""),
296 ),
297 mcp.WithString("eisenhower",
298 mcp.Description("New Eisenhower Matrix quadrant for task prioritization. Valid values: 'both urgent and important', 'urgent, but not important', 'important, but not urgent', 'neither urgent nor important', 'uncategorised' (clears the field). Only include for areas which the user has indicated follow the Eisenhower workflow."),
299 mcp.Enum("both urgent and important", "urgent, but not important", "important, but not urgent", "neither urgent nor important", "uncategorised"),
300 ),
301 mcp.WithString("status",
302 mcp.Description("New task status. Valid values: 'later' (someday/backlog), 'next' (upcoming/soon), 'started' (in progress), 'waiting' (blocked), 'completed' (finished), or empty string to clear. Only include if changing the task status."),
303 mcp.Enum("later", "next", "started", "waiting", "completed", ""),
304 ),
305 mcp.WithString("scheduled_on",
306 mcp.Description("New scheduled date/time for the task. Must use the formatted timestamp returned by get_timestamp tool. Sending an empty string might clear the scheduled date. Only include if changing the schedule."),
307 ),
308 ), toolHandlers.HandleUpdateTask)
309
310 mcpServer.AddTool(mcp.NewTool("delete_task",
311 mcp.WithDescription("Permanently deletes an existing task from Lunatask. This action cannot be undone."),
312 mcp.WithString("task_id",
313 mcp.Description("ID of the task to delete. This must be a valid task ID from an existing task in Lunatask."),
314 mcp.Required(),
315 ),
316 ), toolHandlers.HandleDeleteTask)
317
318 mcpServer.AddTool(
319 mcp.NewTool(
320 "list_habits_and_activities",
321 mcp.WithDescription("Lists all configured habits and their IDs for habit tracking. Use this tool FIRST before track_habit_activity to identify valid habit_id values. Shows habit names, descriptions, and unique identifiers needed for tracking activities."),
322 ),
323 toolHandlers.HandleListHabitsAndActivities,
324 )
325
326 mcpServer.AddTool(mcp.NewTool("track_habit_activity",
327 mcp.WithDescription("Records completion of a habit activity in Lunatask. WORKFLOW: First use list_habits_and_activities to get valid habit_id, then use get_timestamp to format the performed_on date."),
328 mcp.WithString("habit_id",
329 mcp.Description("ID of the habit to track activity for. Must be a valid habit_id from list_habits_and_activities tool."),
330 mcp.Required(),
331 ),
332 mcp.WithString("performed_on",
333 mcp.Description("Timestamp when the habit was performed. Must use the formatted timestamp returned by get_timestamp tool. Examples: if user says 'I did this yesterday', use get_timestamp with 'yesterday'."),
334 mcp.Required(),
335 ),
336 ), toolHandlers.HandleTrackHabitActivity)
337
338 return mcpServer
339}
340
341func createDefaultConfigFile(configPath string) {
342 defaultConfig := Config{
343 Server: ServerConfig{
344 Host: "localhost",
345 Port: 8080,
346 },
347 AccessToken: "",
348 Timezone: "UTC",
349 Areas: []Area{{
350 Name: "Example Area",
351 ID: "area-id-placeholder",
352 Goals: []Goal{{
353 Name: "Example Goal",
354 ID: "goal-id-placeholder",
355 }},
356 }},
357 Habit: []Habit{{
358 Name: "Example Habit",
359 ID: "habit-id-placeholder",
360 }},
361 }
362
363 file, err := os.OpenFile(configPath, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0o600)
364 if err != nil {
365 log.Fatalf("Failed to create default config at %s: %v", configPath, err)
366 }
367 defer closeFile(file)
368
369 if err := toml.NewEncoder(file).Encode(defaultConfig); err != nil {
370 log.Fatalf("Failed to encode default config to %s: %v", configPath, err)
371 }
372
373 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)
374 os.Exit(1)
375}