lunatask-mcp-server.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	"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}