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	"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}