main.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	"encoding/json"
 10	"fmt"
 11	"log"
 12	"os"
 13	"strings"
 14	"time"
 15
 16	"github.com/ijt/go-anytime"
 17
 18	"github.com/BurntSushi/toml"
 19	"github.com/mark3labs/mcp-go/mcp"
 20	"github.com/mark3labs/mcp-go/server"
 21
 22	"git.sr.ht/~amolith/lunatask-mcp-server/lunatask"
 23)
 24
 25// Goal represents a Lunatask goal with its name and ID
 26type Goal struct {
 27	Name string `toml:"name"`
 28	ID   string `toml:"id"`
 29}
 30
 31// Area represents a Lunatask area with its name, ID, and its goals
 32type Area struct {
 33	Name  string `toml:"name"`
 34	ID    string `toml:"id"`
 35	Goals []Goal `toml:"goals"`
 36}
 37
 38// Config holds the application's configuration loaded from TOML
 39type ServerConfig struct {
 40	Host string `toml:"host"`
 41	Port int    `toml:"port"`
 42}
 43
 44type Config struct {
 45	AccessToken string       `toml:"access_token"`
 46	Areas       []Area       `toml:"areas"`
 47	Server      ServerConfig `toml:"server"`
 48	Timezone    string       `toml:"timezone"`
 49}
 50
 51var version = ""
 52
 53func main() {
 54	configPath := "./config.toml"
 55	for i, arg := range os.Args {
 56		switch arg {
 57		case "-v", "--version":
 58			if version == "" {
 59				version = "unknown, build with `just build` or copy/paste the build command from ./justfile"
 60			}
 61			fmt.Println("lunatask-mcp-server:", version)
 62			os.Exit(0)
 63		case "-c", "--config":
 64			if i+1 < len(os.Args) {
 65				configPath = os.Args[i+1]
 66			}
 67		}
 68	}
 69
 70	if _, err := os.Stat(configPath); os.IsNotExist(err) {
 71		createDefaultConfigFile(configPath)
 72	}
 73
 74	var config Config
 75	if _, err := toml.DecodeFile(configPath, &config); err != nil {
 76		log.Fatalf("Failed to load config file %s: %v", configPath, err)
 77	}
 78
 79	if config.AccessToken == "" || len(config.Areas) == 0 {
 80		log.Fatalf("Config file must provide access_token and at least one area.")
 81	}
 82
 83	for i, area := range config.Areas {
 84		if area.Name == "" || area.ID == "" {
 85			log.Fatalf("All areas (areas[%d]) must have both a name and id", i)
 86		}
 87		for j, goal := range area.Goals {
 88			if goal.Name == "" || goal.ID == "" {
 89				log.Fatalf("All goals (areas[%d].goals[%d]) must have both a name and id", i, j)
 90			}
 91		}
 92	}
 93
 94	// Validate timezone config on startup
 95	if _, err := loadLocation(config.Timezone); err != nil {
 96		log.Fatalf("Timezone validation failed: %v", err)
 97	}
 98
 99	mcpServer := NewMCPServer(&config)
100
101	baseURL := fmt.Sprintf("http://%s:%d", config.Server.Host, config.Server.Port)
102	sseServer := server.NewSSEServer(mcpServer, server.WithBaseURL(baseURL))
103	listenAddr := fmt.Sprintf("%s:%d", config.Server.Host, config.Server.Port)
104	log.Printf("SSE server listening on %s (baseURL: %s)", listenAddr, baseURL)
105	if err := sseServer.Start(listenAddr); err != nil {
106		log.Fatalf("Server error: %v", err)
107	}
108}
109
110// loadLocation loads a timezone location string, returning a *time.Location or error
111func loadLocation(timezone string) (*time.Location, error) {
112	if timezone == "" {
113		return nil, fmt.Errorf("timezone is not configured; please set the 'timezone' value in your config file (e.g. 'UTC' or 'America/New_York')")
114	}
115	loc, err := time.LoadLocation(timezone)
116	if err != nil {
117		return nil, fmt.Errorf("could not load timezone '%s': %v", timezone, err)
118	}
119	return loc, nil
120}
121
122// closeFile properly closes a file, handling any errors
123func closeFile(f *os.File) {
124	err := f.Close()
125	if err != nil {
126		log.Printf("Error closing file: %v", err)
127	}
128}
129
130func NewMCPServer(config *Config) *server.MCPServer {
131	hooks := &server.Hooks{}
132
133	hooks.AddBeforeAny(func(ctx context.Context, id any, method mcp.MCPMethod, message any) {
134		fmt.Printf("beforeAny: %s, %v, %v\n", method, id, message)
135	})
136	hooks.AddOnSuccess(func(ctx context.Context, id any, method mcp.MCPMethod, message any, result any) {
137		fmt.Printf("onSuccess: %s, %v, %v, %v\n", method, id, message, result)
138	})
139	hooks.AddOnError(func(ctx context.Context, id any, method mcp.MCPMethod, message any, err error) {
140		fmt.Printf("onError: %s, %v, %v, %v\n", method, id, message, err)
141	})
142	hooks.AddBeforeInitialize(func(ctx context.Context, id any, message *mcp.InitializeRequest) {
143		fmt.Printf("beforeInitialize: %v, %v\n", id, message)
144	})
145	hooks.AddAfterInitialize(func(ctx context.Context, id any, message *mcp.InitializeRequest, result *mcp.InitializeResult) {
146		fmt.Printf("afterInitialize: %v, %v, %v\n", id, message, result)
147	})
148	hooks.AddAfterCallTool(func(ctx context.Context, id any, message *mcp.CallToolRequest, result *mcp.CallToolResult) {
149		fmt.Printf("afterCallTool: %v, %v, %v\n", id, message, result)
150	})
151	hooks.AddBeforeCallTool(func(ctx context.Context, id any, message *mcp.CallToolRequest) {
152		fmt.Printf("beforeCallTool: %v, %v\n", id, message)
153	})
154
155	mcpServer := server.NewMCPServer(
156		"Lunatask MCP Server",
157		"0.1.0",
158		server.WithHooks(hooks),
159		server.WithToolCapabilities(true),
160	)
161
162	mcpServer.AddTool(mcp.NewTool("get_task_timestamp",
163		mcp.WithDescription("Retrieves the formatted timestamp for a task"),
164		mcp.WithString("natural_language_date",
165			mcp.Description("Natural language date as described by the user, e.g. '1 week', 'tomorrow', 'sunday at 19:00', etc."),
166			mcp.Required(),
167		),
168	), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
169		natLangDate, ok := request.Params.Arguments["natural_language_date"].(string)
170		if !ok || natLangDate == "" {
171			return reportMCPError("Missing or invalid required argument: natural_language_date")
172		}
173		loc, err := loadLocation(config.Timezone)
174		if err != nil {
175			return reportMCPError(err.Error())
176		}
177		parsedTime, err := anytime.Parse(natLangDate, time.Now().In(loc))
178		if err != nil {
179			return reportMCPError(fmt.Sprintf("Could not parse natural language date: %v", err))
180		}
181		return &mcp.CallToolResult{
182			Content: []mcp.Content{
183				mcp.TextContent{
184					Type: "text",
185					Text: parsedTime.Format(time.RFC3339),
186				},
187			},
188		}, nil
189	})
190
191	mcpServer.AddTool(
192		mcp.NewTool(
193			"list_areas_and_goals",
194			mcp.WithDescription("List areas and goals and their IDs."),
195		),
196		func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
197			var b strings.Builder
198			for _, area := range config.Areas {
199				fmt.Fprintf(&b, "- %s: %s\n", area.Name, area.ID)
200				for _, goal := range area.Goals {
201					fmt.Fprintf(&b, "  - %s: %s\n", goal.Name, goal.ID)
202				}
203			}
204			return &mcp.CallToolResult{
205				Content: []mcp.Content{
206					mcp.TextContent{
207						Type: "text",
208						Text: b.String(),
209					},
210				},
211			}, nil
212		},
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.WithNumber("priority",
237			mcp.Description("Task priority, -2 being lowest, 0 being normal, and 2 being highest"),
238			mcp.Min(-2),
239			mcp.Max(2),
240		),
241		mcp.WithString("motivation",
242			mcp.Description("Motivation driving task creation"),
243			mcp.Enum("must", "should", "want"),
244		),
245		mcp.WithString("status",
246			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."),
247			mcp.Enum("later", "next", "started", "waiting", "completed"),
248		),
249		mcp.WithString("scheduled_on",
250			mcp.Description("Formatted timestamp from get_task_timestamp tool"),
251		),
252	), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
253		return handleCreateTask(ctx, request, config)
254	})
255
256	return mcpServer
257}
258
259func reportMCPError(msg string) (*mcp.CallToolResult, error) {
260	return &mcp.CallToolResult{
261		IsError: true,
262		Content: []mcp.Content{mcp.TextContent{Type: "text", Text: msg}},
263	}, nil
264}
265
266// handleCreateTask handles the creation of a task in Lunatask
267func handleCreateTask(
268	ctx context.Context,
269	request mcp.CallToolRequest,
270	config *Config,
271) (*mcp.CallToolResult, error) {
272	arguments := request.Params.Arguments
273
274	// Validate timezone before proceeding any further
275	if _, err := loadLocation(config.Timezone); err != nil {
276		return reportMCPError(err.Error())
277	}
278
279	areaID, ok := arguments["area_id"].(string)
280	if !ok || areaID == "" {
281		return reportMCPError("Missing or invalid required argument: area_id")
282	}
283
284	var area *Area
285	for i := range config.Areas {
286		if config.Areas[i].ID == areaID {
287			area = &config.Areas[i]
288			break
289		}
290	}
291	if area == nil {
292		return reportMCPError("Area not found for given area_id")
293	}
294
295	if goalID, exists := arguments["goal_id"].(string); exists && goalID != "" {
296		found := false
297		for _, goal := range area.Goals {
298			if goal.ID == goalID {
299				found = true
300				break
301			}
302		}
303		if !found {
304			return reportMCPError("Goal not found in specified area for given goal_id")
305		}
306	}
307
308	if priorityVal, exists := arguments["priority"]; exists && priorityVal != nil {
309		if priority, ok := priorityVal.(float64); ok {
310			if priority < -2 || priority > 2 {
311				return reportMCPError("'priority' must be between -2 and 2 (inclusive)")
312			}
313		} else {
314			return reportMCPError("'priority' must be a number")
315		}
316	}
317
318	if motivationVal, exists := arguments["motivation"]; exists && motivationVal != nil {
319		if motivation, ok := motivationVal.(string); ok && motivation != "" {
320			validMotivations := map[string]bool{"must": true, "should": true, "want": true}
321			if !validMotivations[motivation] {
322				return reportMCPError("'motivation' must be one of 'must', 'should', or 'want'")
323			}
324		} else if ok {
325			// empty string is allowed
326		} else {
327			return reportMCPError("'motivation' must be a string")
328		}
329	}
330
331	if statusVal, exists := arguments["status"]; exists && statusVal != nil {
332		if status, ok := statusVal.(string); ok && status != "" {
333			validStatus := map[string]bool{"later": true, "next": true, "started": true, "waiting": true, "completed": true}
334			if !validStatus[status] {
335				return reportMCPError("'status' must be one of 'later', 'next', 'started', 'waiting', or 'completed'")
336			}
337		} else if ok {
338			// empty string is allowed
339		} else {
340			return reportMCPError("'status' must be a string")
341		}
342	}
343
344	// Validate scheduled_on format if provided
345	if scheduledOnArg, exists := arguments["scheduled_on"]; exists {
346		if scheduledOnStr, ok := scheduledOnArg.(string); ok && scheduledOnStr != "" {
347			if _, err := time.Parse(time.RFC3339, scheduledOnStr); err != nil {
348				return reportMCPError(fmt.Sprintf("Invalid format for scheduled_on: '%s'. Must be RFC3339 timestamp (e.g., YYYY-MM-DDTHH:MM:SSZ). Use get_task_timestamp tool first.", scheduledOnStr))
349			}
350		} else if !ok {
351			// It exists but isn't a string, which shouldn't happen based on MCP schema but check anyway
352			return reportMCPError("Invalid type for scheduled_on argument: expected string.")
353		}
354		// If it's an empty string, it's handled by the API or omitempty later, no need to validate format.
355	}
356
357	// Create Lunatask client
358	client := lunatask.NewClient(config.AccessToken)
359
360	// Prepare the task request
361	var task lunatask.CreateTaskRequest
362	argBytes, err := json.Marshal(arguments)
363	if err != nil {
364		return reportMCPError(fmt.Sprintf("Failed to process arguments: %v", err))
365	}
366	if err := json.Unmarshal(argBytes, &task); err != nil {
367		return reportMCPError(fmt.Sprintf("Failed to parse arguments: %v", err))
368	}
369
370	// Call the client to create the task
371	response, err := client.CreateTask(ctx, &task)
372	if err != nil {
373		return reportMCPError(fmt.Sprintf("%v", err))
374	}
375
376	// Handle the case where task already exists
377	if response == nil {
378		return &mcp.CallToolResult{
379			Content: []mcp.Content{
380				mcp.TextContent{
381					Type: "text",
382					Text: "Task already exists (not an error).",
383				},
384			},
385		}, nil
386	}
387
388	return &mcp.CallToolResult{
389		Content: []mcp.Content{
390			mcp.TextContent{
391				Type: "text",
392				Text: fmt.Sprintf("Task created successfully with ID: %s", response.Task.ID),
393			},
394		},
395	}, nil
396}
397
398func createDefaultConfigFile(configPath string) {
399	defaultConfig := Config{
400		Server: ServerConfig{
401			Host: "localhost",
402			Port: 8080,
403		},
404		AccessToken: "",
405		Timezone:    "UTC",
406		Areas: []Area{{
407			Name: "Example Area",
408			ID:   "area-id-placeholder",
409			Goals: []Goal{{
410				Name: "Example Goal",
411				ID:   "goal-id-placeholder",
412			}},
413		}},
414	}
415	file, err := os.OpenFile(configPath, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0o600)
416	if err != nil {
417		log.Fatalf("Failed to create default config at %s: %v", configPath, err)
418	}
419	defer closeFile(file)
420	if err := toml.NewEncoder(file).Encode(defaultConfig); err != nil {
421		log.Fatalf("Failed to encode default config to %s: %v", configPath, err)
422	}
423	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)
424	os.Exit(1)
425}