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:"goal"`
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:"area"`
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("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."),
201 mcp.WithString("natural_language_date",
202 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."),
203 mcp.Required(),
204 ),
205 ), toolHandlers.HandleGetTimestamp)
206
207 mcpServer.AddTool(
208 mcp.NewTool(
209 "list_areas_and_goals",
210 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."),
211 ),
212 toolHandlers.HandleListAreasAndGoals,
213 )
214
215 mcpServer.AddTool(mcp.NewTool("create_task",
216 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."),
217 mcp.WithString("area_id",
218 mcp.Description("Area ID in which to create the task. Must be a valid area_id from list_areas_and_goals tool."),
219 mcp.Required(),
220 ),
221 mcp.WithString("goal_id",
222 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."),
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("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."),
230 ),
231 mcp.WithNumber("estimate",
232 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."),
233 mcp.Min(0),
234 mcp.Max(720),
235 ),
236 mcp.WithString("priority",
237 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."),
238 mcp.Enum("lowest", "low", "neutral", "high", "highest"),
239 ),
240 mcp.WithString("motivation",
241 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')."),
242 mcp.Enum("must", "should", "want"),
243 ),
244 mcp.WithString("eisenhower",
245 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."),
246 mcp.Enum("both urgent and important", "urgent, but not important", "important, but not urgent", "neither urgent nor important", "uncategorised"),
247 ),
248 mcp.WithString("status",
249 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)."),
250 mcp.Enum("later", "next", "started", "waiting", "completed"),
251 ),
252 mcp.WithString("scheduled_on",
253 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."),
254 ),
255 ), toolHandlers.HandleCreateTask)
256
257 mcpServer.AddTool(mcp.NewTool("update_task",
258 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."),
259 mcp.WithString("task_id",
260 mcp.Description("ID of the task to update."),
261 mcp.Required(),
262 ),
263 mcp.WithString("area_id",
264 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."),
265 ),
266 mcp.WithString("goal_id",
267 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."),
268 ),
269 mcp.WithString("name",
270 mcp.Description("New plain text task name using sentence case. Sending an empty string WILL clear the name."),
271 mcp.Required(),
272 ),
273 mcp.WithString("note",
274 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."),
275 ),
276 mcp.WithNumber("estimate",
277 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."),
278 mcp.Min(0),
279 mcp.Max(720),
280 ),
281 mcp.WithString("priority",
282 mcp.Description("New task priority level. Valid values: 'lowest', 'low', 'neutral', 'high', 'highest'. Only include if user wants to change the priority."),
283 mcp.Enum("lowest", "low", "neutral", "high", "highest"),
284 ),
285 mcp.WithString("motivation",
286 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."),
287 mcp.Enum("must", "should", "want", ""),
288 ),
289 mcp.WithString("eisenhower",
290 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."),
291 mcp.Enum("both urgent and important", "urgent, but not important", "important, but not urgent", "neither urgent nor important", "uncategorised"),
292 ),
293 mcp.WithString("status",
294 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."),
295 mcp.Enum("later", "next", "started", "waiting", "completed", ""),
296 ),
297 mcp.WithString("scheduled_on",
298 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."),
299 ),
300 ), toolHandlers.HandleUpdateTask)
301
302 mcpServer.AddTool(mcp.NewTool("delete_task",
303 mcp.WithDescription("Permanently deletes an existing task from Lunatask. This action cannot be undone."),
304 mcp.WithString("task_id",
305 mcp.Description("ID of the task to delete. This must be a valid task ID from an existing task in Lunatask."),
306 mcp.Required(),
307 ),
308 ), toolHandlers.HandleDeleteTask)
309
310 mcpServer.AddTool(
311 mcp.NewTool(
312 "list_habits_and_activities",
313 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."),
314 ),
315 toolHandlers.HandleListHabitsAndActivities,
316 )
317
318 mcpServer.AddTool(mcp.NewTool("track_habit_activity",
319 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."),
320 mcp.WithString("habit_id",
321 mcp.Description("ID of the habit to track activity for. Must be a valid habit_id from list_habits_and_activities tool."),
322 mcp.Required(),
323 ),
324 mcp.WithString("performed_on",
325 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'."),
326 mcp.Required(),
327 ),
328 ), toolHandlers.HandleTrackHabitActivity)
329
330 return mcpServer
331}
332
333func createDefaultConfigFile(configPath string) {
334 defaultConfig := Config{
335 Server: ServerConfig{
336 Host: "localhost",
337 Port: 8080,
338 },
339 AccessToken: "",
340 Timezone: "UTC",
341 Areas: []Area{{
342 Name: "Example Area",
343 ID: "area-id-placeholder",
344 Goals: []Goal{{
345 Name: "Example Goal",
346 ID: "goal-id-placeholder",
347 }},
348 }},
349 Habit: []Habit{{
350 Name: "Example Habit",
351 ID: "habit-id-placeholder",
352 }},
353 }
354 file, err := os.OpenFile(configPath, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0o600)
355 if err != nil {
356 log.Fatalf("Failed to create default config at %s: %v", configPath, err)
357 }
358 defer closeFile(file)
359 if err := toml.NewEncoder(file).Encode(defaultConfig); err != nil {
360 log.Fatalf("Failed to encode default config to %s: %v", configPath, err)
361 }
362 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)
363 os.Exit(1)
364}